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/threadstate.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Per-thread annotations: your judgement about a conversation.
|
|
2
|
+
|
|
3
|
+
The third layer of the workspace, distinct from both neighbours:
|
|
4
|
+
|
|
5
|
+
- `cache/` holds what the portal says. Disposable, refetchable, never edited.
|
|
6
|
+
- `threads/<mod>.yaml` (this module) holds what *you* decided about the
|
|
7
|
+
conversation - whether it is dealt with, whether you owe a reply, what it
|
|
8
|
+
really is regardless of how the reporter tagged it.
|
|
9
|
+
- `tracker/` holds what you are doing about the underlying problem.
|
|
10
|
+
|
|
11
|
+
Without this layer the only way to record any judgement about a thread is to
|
|
12
|
+
create a ticket, which forces a ticket per thread. Most threads never deserve
|
|
13
|
+
one: thanks, answered questions, duplicates.
|
|
14
|
+
|
|
15
|
+
The thread -> ticket link is deliberately NOT stored here. It is derived from
|
|
16
|
+
each ticket's `sources:`, so the two cannot drift apart.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
from .config import Workspace, yaml_load
|
|
26
|
+
|
|
27
|
+
# How you have dealt with the conversation itself.
|
|
28
|
+
TRIAGE = ("pending", "handled", "ignored")
|
|
29
|
+
|
|
30
|
+
# What the thread actually is, in your judgement. The portal's own tag is only
|
|
31
|
+
# a hint: reporters mislabel constantly - questions filed as bugs by shy users,
|
|
32
|
+
# expected behaviour filed as errors by careless ones - so nothing keys
|
|
33
|
+
# automatic behaviour off it.
|
|
34
|
+
CLASSES = ("error", "feature", "question", "wrong", "duplicate", "chatter")
|
|
35
|
+
|
|
36
|
+
DEFAULT_TRIAGE = "pending"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ThreadState:
|
|
41
|
+
triage: str = DEFAULT_TRIAGE
|
|
42
|
+
needs_response: bool = False
|
|
43
|
+
cls: str = ""
|
|
44
|
+
note: str = ""
|
|
45
|
+
duplicate_of: str = ""
|
|
46
|
+
# Ticket ids whose release you have already told this thread's reporter
|
|
47
|
+
# about. Prevents a shipped fix from nagging forever once announced.
|
|
48
|
+
announced: list[str] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict:
|
|
51
|
+
d: dict = {}
|
|
52
|
+
if self.triage != DEFAULT_TRIAGE:
|
|
53
|
+
d["triage"] = self.triage
|
|
54
|
+
if self.needs_response:
|
|
55
|
+
d["needs_response"] = True
|
|
56
|
+
if self.cls:
|
|
57
|
+
d["class"] = self.cls
|
|
58
|
+
if self.note:
|
|
59
|
+
d["note"] = self.note
|
|
60
|
+
if self.duplicate_of:
|
|
61
|
+
d["duplicate_of"] = self.duplicate_of
|
|
62
|
+
if self.announced:
|
|
63
|
+
d["announced"] = sorted(self.announced)
|
|
64
|
+
return d
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_dict(cls, d: dict | None) -> "ThreadState":
|
|
68
|
+
d = d or {}
|
|
69
|
+
return cls(
|
|
70
|
+
triage=d.get("triage", DEFAULT_TRIAGE),
|
|
71
|
+
needs_response=bool(d.get("needs_response", False)),
|
|
72
|
+
cls=d.get("class", "") or "",
|
|
73
|
+
note=d.get("note", "") or "",
|
|
74
|
+
duplicate_of=d.get("duplicate_of", "") or "",
|
|
75
|
+
announced=list(d.get("announced") or []),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def is_open(self) -> bool:
|
|
80
|
+
return self.triage == "pending" or self.needs_response
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def pending_announcements(state: "ThreadState", tickets) -> list[str]:
|
|
84
|
+
"""Released tickets citing this thread that the reporter has not been told about.
|
|
85
|
+
|
|
86
|
+
Derived rather than stored: a fix reaching `released` is what creates the
|
|
87
|
+
obligation, and deriving it means the obligation cannot be missed because
|
|
88
|
+
nobody remembered to set a flag. It is still cleared only explicitly, via
|
|
89
|
+
`fmw mark --responded`, which records the ticket ids in `announced`.
|
|
90
|
+
"""
|
|
91
|
+
return [
|
|
92
|
+
t.id for t in tickets
|
|
93
|
+
if t.meta.get("status") == "released" and t.id not in state.announced
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def reply_reason(state: "ThreadState", tickets) -> str:
|
|
98
|
+
"""Why this thread owes a reply, or an empty string if it does not."""
|
|
99
|
+
if state.needs_response:
|
|
100
|
+
return "flagged"
|
|
101
|
+
pending = pending_announcements(state, tickets)
|
|
102
|
+
if pending:
|
|
103
|
+
return "released: " + ", ".join(pending)
|
|
104
|
+
return ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _file(ws: Workspace, mod: str):
|
|
108
|
+
return ws.threads_dir / f"{mod}.yaml"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load(ws: Workspace, mod: str) -> dict[str, ThreadState]:
|
|
112
|
+
path = _file(ws, mod)
|
|
113
|
+
if not path.exists():
|
|
114
|
+
return {}
|
|
115
|
+
raw = yaml_load(path.read_text()) or {}
|
|
116
|
+
return {tid: ThreadState.from_dict(d) for tid, d in (raw.get("threads") or {}).items()}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def save(ws: Workspace, mod: str, states: dict[str, ThreadState]) -> None:
|
|
120
|
+
payload = {tid: s.to_dict() for tid, s in sorted(states.items()) if s.to_dict()}
|
|
121
|
+
path = _file(ws, mod)
|
|
122
|
+
if not payload:
|
|
123
|
+
if path.exists():
|
|
124
|
+
path.unlink()
|
|
125
|
+
return
|
|
126
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
header = (
|
|
128
|
+
f"# Your judgement about {mod} discussion threads, keyed by thread id.\n"
|
|
129
|
+
"# Portable: no local paths, so it can be committed and shared.\n"
|
|
130
|
+
"# The thread -> ticket link is not here; it is derived from ticket sources.\n"
|
|
131
|
+
)
|
|
132
|
+
path.write_text(
|
|
133
|
+
header + yaml.safe_dump({"threads": payload}, sort_keys=False,
|
|
134
|
+
allow_unicode=True, width=100)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get(ws: Workspace, mod: str, thread_id: str) -> ThreadState:
|
|
139
|
+
return load(ws, mod).get(thread_id, ThreadState())
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def update(ws: Workspace, mod: str, thread_id: str, **changes) -> ThreadState:
|
|
143
|
+
states = load(ws, mod)
|
|
144
|
+
state = states.get(thread_id, ThreadState())
|
|
145
|
+
for key, value in changes.items():
|
|
146
|
+
if value is not None:
|
|
147
|
+
setattr(state, key, value)
|
|
148
|
+
states[thread_id] = state
|
|
149
|
+
save(ws, mod, states)
|
|
150
|
+
return state
|
fmw/tracker.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Curated tracker items: bugs, feature ideas and development plans.
|
|
2
|
+
|
|
3
|
+
One Markdown file per item, YAML frontmatter for structure and a free-form
|
|
4
|
+
body for analysis. Plain files so both humans and agents can read, grep,
|
|
5
|
+
diff and edit them without any tooling in the way.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import secrets
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import date
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from .config import Workspace, yaml_load
|
|
19
|
+
|
|
20
|
+
# Tickets describe code changes only. A thread that needs an answer rather than
|
|
21
|
+
# a change is resolved at the thread level and never becomes a ticket; roadmap
|
|
22
|
+
# notes live in the workspace's notes/ directory.
|
|
23
|
+
KINDS = ("error", "feature")
|
|
24
|
+
|
|
25
|
+
# Vocabulary from earlier versions, mapped on migration.
|
|
26
|
+
LEGACY_KINDS = {"bug": "error", "compat": "feature", "port": "feature"}
|
|
27
|
+
STATUSES = (
|
|
28
|
+
"new",
|
|
29
|
+
"needs-info",
|
|
30
|
+
"confirmed",
|
|
31
|
+
"in-progress",
|
|
32
|
+
"needs-testing",
|
|
33
|
+
"fixed",
|
|
34
|
+
"released",
|
|
35
|
+
"wontfix",
|
|
36
|
+
"duplicate",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?(.*)\Z", re.S)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Item:
|
|
44
|
+
path: Path
|
|
45
|
+
meta: dict
|
|
46
|
+
body: str
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def id(self) -> str:
|
|
50
|
+
return self.meta.get("id") or self.path.stem
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def mods(self) -> list[str]:
|
|
54
|
+
"""Every mod this ticket applies to; the first is the primary one.
|
|
55
|
+
|
|
56
|
+
Accepts the older singular `mod:` key so existing files keep working.
|
|
57
|
+
"""
|
|
58
|
+
raw = self.meta.get("mods")
|
|
59
|
+
if isinstance(raw, str):
|
|
60
|
+
return [raw]
|
|
61
|
+
if raw:
|
|
62
|
+
return [str(m) for m in raw]
|
|
63
|
+
single = self.meta.get("mod")
|
|
64
|
+
return [single] if single else []
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def mod(self) -> str:
|
|
68
|
+
mods = self.mods
|
|
69
|
+
return mods[0] if mods else ""
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def sources(self) -> list[str]:
|
|
73
|
+
raw = self.meta.get("sources") or []
|
|
74
|
+
return [str(s) for s in raw]
|
|
75
|
+
|
|
76
|
+
def portal_threads(self) -> set[str]:
|
|
77
|
+
return {s.split(":", 1)[1] for s in self.sources if s.startswith("portal:")}
|
|
78
|
+
|
|
79
|
+
def render(self) -> str:
|
|
80
|
+
fm = yaml.safe_dump(self.meta, sort_keys=False, allow_unicode=True, width=100)
|
|
81
|
+
return f"---\n{fm}---\n\n{self.body.strip()}\n"
|
|
82
|
+
|
|
83
|
+
def save(self) -> None:
|
|
84
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
self.path.write_text(self.render())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse(path: Path) -> Item:
|
|
89
|
+
text = path.read_text()
|
|
90
|
+
m = FRONTMATTER_RE.match(text)
|
|
91
|
+
if not m:
|
|
92
|
+
return Item(path=path, meta={}, body=text)
|
|
93
|
+
meta = yaml_load(m.group(1)) or {}
|
|
94
|
+
return Item(path=path, meta=meta, body=m.group(2))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_all(ws: Workspace, mod: str | None = None) -> list[Item]:
|
|
98
|
+
"""All tickets, or those applying to one mod.
|
|
99
|
+
|
|
100
|
+
A ticket lives in its primary mod's directory but may list others in
|
|
101
|
+
`mods:`, so filtering by mod has to read every ticket rather than one
|
|
102
|
+
directory. That costs a few milliseconds at any realistic ticket count.
|
|
103
|
+
"""
|
|
104
|
+
root = ws.tracker_dir
|
|
105
|
+
if not root.is_dir():
|
|
106
|
+
return []
|
|
107
|
+
items = [parse(p) for p in sorted(root.rglob("*.md")) if p.name != "README.md"]
|
|
108
|
+
if mod:
|
|
109
|
+
items = [i for i in items if mod in i.mods]
|
|
110
|
+
return items
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
ID_RE = re.compile(r"^[0-9a-f]{6}$")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def existing_ids(ws: Workspace) -> set[str]:
|
|
117
|
+
return {i.id for i in load_all(ws)}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def new_id(taken: set[str]) -> str:
|
|
121
|
+
"""A short, stable, meaningless identifier.
|
|
122
|
+
|
|
123
|
+
Meaningless on purpose: reporters title threads "game crashes" and similar,
|
|
124
|
+
so a title-derived id is neither unique nor informative, and renaming the
|
|
125
|
+
ticket would silently break every reference to it.
|
|
126
|
+
"""
|
|
127
|
+
while True:
|
|
128
|
+
candidate = secrets.token_hex(3)
|
|
129
|
+
if candidate not in taken:
|
|
130
|
+
return candidate
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def slugify(text: str, maxlen: int = 48) -> str:
|
|
134
|
+
s = re.sub(r"[^a-zA-Z0-9]+", "-", text.lower()).strip("-")
|
|
135
|
+
if len(s) > maxlen:
|
|
136
|
+
s = s[:maxlen].rsplit("-", 1)[0]
|
|
137
|
+
return s or "item"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def create(
|
|
141
|
+
ws: Workspace,
|
|
142
|
+
mods: list[str],
|
|
143
|
+
title: str,
|
|
144
|
+
kind: str = "error",
|
|
145
|
+
status: str = "new",
|
|
146
|
+
sources: list[str] | None = None,
|
|
147
|
+
body: str = "",
|
|
148
|
+
extra: dict | None = None,
|
|
149
|
+
) -> Item:
|
|
150
|
+
ident = new_id(existing_ids(ws))
|
|
151
|
+
primary = mods[0]
|
|
152
|
+
# The id is what references use; the slug is decoration so `ls tracker/` is
|
|
153
|
+
# readable, and may be renamed freely without breaking anything.
|
|
154
|
+
path = ws.mod_tracker_dir(primary) / f"{ident}-{slugify(title, 40)}.md"
|
|
155
|
+
today = date.today().isoformat()
|
|
156
|
+
meta = {
|
|
157
|
+
"id": ident,
|
|
158
|
+
"mods": list(mods),
|
|
159
|
+
"kind": kind,
|
|
160
|
+
"status": status,
|
|
161
|
+
"title": title,
|
|
162
|
+
"created": today,
|
|
163
|
+
"updated": today,
|
|
164
|
+
"sources": sources or [],
|
|
165
|
+
}
|
|
166
|
+
if extra:
|
|
167
|
+
meta.update(extra)
|
|
168
|
+
item = Item(path=path, meta=meta, body=body or DEFAULT_BODY)
|
|
169
|
+
item.save()
|
|
170
|
+
return item
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
DEFAULT_BODY = """## Summary
|
|
174
|
+
|
|
175
|
+
_What the user reported / what the idea is._
|
|
176
|
+
|
|
177
|
+
## Analysis
|
|
178
|
+
|
|
179
|
+
_Root cause, affected code, Factorio API details._
|
|
180
|
+
|
|
181
|
+
## Resolution
|
|
182
|
+
|
|
183
|
+
_What was changed, in which files, in which version._
|
|
184
|
+
|
|
185
|
+
## Test plan
|
|
186
|
+
|
|
187
|
+
_Concrete steps to verify in game. Written for a single playthrough shared
|
|
188
|
+
with other fixes, so keep it short and say what a pass looks like._
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def index_by_thread(items: list[Item]) -> dict[str, list[Item]]:
|
|
193
|
+
"""Map portal thread id -> tracker items referencing it."""
|
|
194
|
+
idx: dict[str, list[Item]] = {}
|
|
195
|
+
for it in items:
|
|
196
|
+
for tid in it.portal_threads():
|
|
197
|
+
idx.setdefault(tid, []).append(it)
|
|
198
|
+
return idx
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def open_statuses() -> set[str]:
|
|
202
|
+
"""Statuses that still need someone to do something."""
|
|
203
|
+
return {"new", "needs-info", "confirmed", "in-progress", "needs-testing"}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
TEST_HEADING = "## Test plan"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_plan(item: "Item") -> str:
|
|
210
|
+
"""The item's Test plan section, if it has one."""
|
|
211
|
+
lines = item.body.splitlines()
|
|
212
|
+
out: list[str] = []
|
|
213
|
+
collecting = False
|
|
214
|
+
for line in lines:
|
|
215
|
+
if line.strip().lower().startswith(TEST_HEADING.lower()):
|
|
216
|
+
collecting = True
|
|
217
|
+
continue
|
|
218
|
+
if collecting and line.startswith("## "):
|
|
219
|
+
break
|
|
220
|
+
if collecting:
|
|
221
|
+
out.append(line)
|
|
222
|
+
text = "\n".join(out).strip()
|
|
223
|
+
return "" if text.startswith("_") and text.endswith("_") else text
|