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/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