fxcss 0.6.1__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.
fxcss/fetch.py ADDED
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env python3
2
+ """Fetch a theme from GitHub so it can be test-driven.
3
+
4
+ `fxcss try owner/repo` downloads a theme, installs it into a throwaway profile
5
+ and opens it, so someone can see what a theme actually looks like before putting
6
+ it anywhere near their real Firefox profile.
7
+
8
+ **Nothing from the downloaded repository is ever executed.** A theme's install
9
+ script is, in practice, `cp -r chrome/ <profile>/` plus flipping a pref -- which
10
+ is precisely what fxcss already does itself. So this locates and *reports* the
11
+ install script and any options it documents, and then does the installation by
12
+ copying files. Running a shell script fetched from a URL to preview a stylesheet
13
+ would be a poor trade.
14
+
15
+ That leaves the theme's own contents, which are CSS, SVG and occasionally a .js
16
+ file. Firefox does not execute a .js file sitting in a profile's chrome folder --
17
+ that needs an autoconfig hook in the *application* directory, which fxcss does
18
+ not create.
19
+ """
20
+
21
+ import io
22
+ import json
23
+ import os
24
+ import re
25
+ import shutil
26
+ import tarfile
27
+ import tempfile
28
+ import urllib.error
29
+ import urllib.request
30
+ from pathlib import Path
31
+
32
+ API = "https://api.github.com"
33
+ USER_AGENT = "fxcss (+https://github.com/AdamXweb/fxcss)"
34
+
35
+ # A theme is small. Anything wildly bigger than this is not one, and unpacking it
36
+ # blindly would be careless.
37
+ MAX_ARCHIVE_BYTES = 80 * 1024 * 1024
38
+ MAX_UNPACKED_BYTES = 200 * 1024 * 1024
39
+
40
+ REPO_SPEC = re.compile(
41
+ r"^(?:https?://)?(?:www\.)?(?:github\.com/)?([\w.-]+)/([\w.-]+?)(?:\.git)?/?$")
42
+
43
+ INSTALL_SCRIPTS = [
44
+ "install.sh", "install.bat", "install.ps1", "install.py",
45
+ "setup.sh", "Makefile", "makefile", "justfile",
46
+ ]
47
+
48
+ # Themes commonly keep optional tweaks in one of these.
49
+ VARIANT_DIRS = ["custom", "optional", "options", "extras", "variants"]
50
+
51
+ # "- `-c` Left hand side tab close button" and similar list items.
52
+ FLAG_LINE = re.compile(r"^\s*[-*+]\s*`?(-{1,2}[\w-]+)`?\s*[—:-]?\s+(.{4,120}?)\s*$")
53
+
54
+
55
+ def parse_repo(spec: str):
56
+ match = REPO_SPEC.match(spec.strip())
57
+ if not match:
58
+ raise ValueError(
59
+ f"could not read {spec!r} as a GitHub repo. "
60
+ "Use owner/name or a github.com URL.")
61
+ return match.group(1), match.group(2)
62
+
63
+
64
+ def _request(url, accept="application/vnd.github+json"):
65
+ headers = {"User-Agent": USER_AGENT, "Accept": accept}
66
+ # Only raises the anonymous rate limit; never required.
67
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
68
+ if token:
69
+ headers["Authorization"] = f"Bearer {token}"
70
+ return urllib.request.Request(url, headers=headers)
71
+
72
+
73
+ def _api(path):
74
+ try:
75
+ with urllib.request.urlopen(_request(API + path), timeout=45) as response:
76
+ return json.loads(response.read().decode("utf-8"))
77
+ except urllib.error.HTTPError as exc:
78
+ if exc.code == 404:
79
+ return None
80
+ if exc.code == 403:
81
+ raise RuntimeError(
82
+ "GitHub refused the request (rate limit). Set GITHUB_TOKEN to "
83
+ "raise it, or pass --ref to skip the lookups.") from exc
84
+ raise
85
+
86
+
87
+ def resolve(owner, name):
88
+ """Report what is available: the default branch tip and the latest release."""
89
+ repo = _api(f"/repos/{owner}/{name}")
90
+ if repo is None:
91
+ raise RuntimeError(f"no such repository: {owner}/{name}")
92
+
93
+ branch = repo.get("default_branch", "main")
94
+ commit = _api(f"/repos/{owner}/{name}/commits/{branch}") or {}
95
+ release = _api(f"/repos/{owner}/{name}/releases/latest")
96
+
97
+ info = {
98
+ "owner": owner, "name": name,
99
+ "description": repo.get("description") or "",
100
+ "stars": repo.get("stargazers_count", 0),
101
+ "licence": (repo.get("license") or {}).get("spdx_id"),
102
+ "default_branch": branch,
103
+ "commit": {
104
+ "sha": (commit.get("sha") or "")[:7],
105
+ "date": (commit.get("commit", {}).get("author", {}) or {}).get("date", ""),
106
+ "message": (commit.get("commit", {}).get("message") or "").splitlines()[0][:72],
107
+ } if commit else None,
108
+ "release": {
109
+ "tag": release.get("tag_name"),
110
+ "name": release.get("name") or release.get("tag_name"),
111
+ "date": release.get("published_at", ""),
112
+ } if release else None,
113
+ }
114
+ return info
115
+
116
+
117
+ def choose_ref(info, prefer):
118
+ """Pick what to download. Releases are what a theme's author blessed."""
119
+ if prefer == "commit":
120
+ return info["default_branch"], "latest commit"
121
+ if info["release"]:
122
+ return info["release"]["tag"], f"release {info['release']['tag']}"
123
+ return info["default_branch"], f"latest commit on {info['default_branch']}"
124
+
125
+
126
+ def _safe_members(archive, destination: Path):
127
+ """Yield members that stay inside the destination, refusing anything else."""
128
+ total = 0
129
+ root = destination.resolve()
130
+ for member in archive:
131
+ if member.issym() or member.islnk():
132
+ continue # a link could point anywhere; a theme does not need them
133
+ if not (member.isfile() or member.isdir()):
134
+ continue
135
+ target = (root / member.name).resolve()
136
+ if not str(target).startswith(str(root) + os.sep):
137
+ raise RuntimeError(f"archive tried to write outside the target: {member.name}")
138
+ total += max(member.size, 0)
139
+ if total > MAX_UNPACKED_BYTES:
140
+ raise RuntimeError("archive unpacks to more than 200 MB; refusing")
141
+ yield member
142
+
143
+
144
+ def download(owner, name, ref, into: Path):
145
+ """Download and unpack a repository at a ref. Returns the unpacked root."""
146
+ url = f"https://codeload.github.com/{owner}/{name}/tar.gz/{ref}"
147
+ try:
148
+ with urllib.request.urlopen(_request(url, accept="*/*"), timeout=180) as response:
149
+ payload = response.read(MAX_ARCHIVE_BYTES + 1)
150
+ except urllib.error.HTTPError as exc:
151
+ raise RuntimeError(f"could not download {owner}/{name}@{ref}: {exc}") from exc
152
+ if len(payload) > MAX_ARCHIVE_BYTES:
153
+ raise RuntimeError("archive is larger than 80 MB; refusing")
154
+
155
+ into.mkdir(parents=True, exist_ok=True)
156
+ with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive:
157
+ archive.extractall(into, members=_safe_members(archive, into))
158
+
159
+ entries = [p for p in into.iterdir() if p.is_dir()]
160
+ if len(entries) == 1:
161
+ return entries[0] # GitHub wraps everything in one directory
162
+ return into
163
+
164
+
165
+ def find_theme_root(unpacked: Path):
166
+ """Locate the folder that holds chrome/userChrome.css.
167
+
168
+ Not always the repository root: plenty of themes keep sources under src/ or
169
+ ship several variants side by side.
170
+ """
171
+ candidates = sorted(unpacked.rglob("userChrome.css"),
172
+ key=lambda p: len(p.relative_to(unpacked).parts))
173
+ for sheet in candidates:
174
+ if sheet.parent.name == "chrome":
175
+ return sheet.parent.parent
176
+ if candidates:
177
+ # userChrome.css not inside a folder called chrome; treat its parent as
178
+ # the chrome folder anyway.
179
+ return candidates[0].parent.parent
180
+ return None
181
+
182
+
183
+ def describe(repo_root: Path, theme_root: Path):
184
+ """What this theme offers, and how its author says to install it."""
185
+ scripts = [name for name in INSTALL_SCRIPTS if (repo_root / name).is_file()]
186
+
187
+ variants = []
188
+ for folder in VARIANT_DIRS:
189
+ directory = repo_root / folder
190
+ if directory.is_dir():
191
+ for sheet in sorted(directory.glob("*.css")):
192
+ variants.append(sheet)
193
+
194
+ flags = []
195
+ for readme in ("README.md", "readme.md", "README", "README.rst"):
196
+ path = repo_root / readme
197
+ if not path.is_file():
198
+ continue
199
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
200
+ match = FLAG_LINE.match(line)
201
+ if match:
202
+ flags.append({"flag": match.group(1), "text": match.group(2)})
203
+ break
204
+
205
+ sheets = list(theme_root.rglob("*.css")) if theme_root else []
206
+ return {
207
+ "scripts": scripts,
208
+ "variants": variants,
209
+ "flags": flags[:14],
210
+ "stylesheets": len(sheets),
211
+ "bytes": sum(p.stat().st_size for p in sheets),
212
+ "has_configuration": (theme_root / "configuration").is_dir() if theme_root else False,
213
+ }
214
+
215
+
216
+ def apply_variants(profile: Path, chosen):
217
+ """Layer optional stylesheets on top, by import rather than by copying.
218
+
219
+ Themes disagree about where an optional sheet belongs -- some expect it
220
+ inside their own folder, some rely on their installer editing an import
221
+ list. Appending an absolute @import to the profile's userChrome.css works
222
+ for all of them, and relative url()s inside the sheet still resolve because
223
+ the file stays where it was unpacked.
224
+ """
225
+ user_chrome = profile / "chrome" / "userChrome.css"
226
+ if not user_chrome.exists() or not chosen:
227
+ return []
228
+ lines = ["", "/* optional sheets layered on by fxcss try --with */"]
229
+ for sheet in chosen:
230
+ lines.append(f'@import url("{sheet.resolve().as_uri()}");')
231
+ with user_chrome.open("a", encoding="utf-8") as handle:
232
+ handle.write("\n".join(lines) + "\n")
233
+ return chosen
234
+
235
+
236
+ def humanise(info):
237
+ """Lines describing what was found upstream."""
238
+ out = []
239
+ header = f"{info['owner']}/{info['name']}"
240
+ if info["stars"]:
241
+ header += f" ★{info['stars']}"
242
+ if info["licence"] and info["licence"] != "NOASSERTION":
243
+ header += f" {info['licence']}"
244
+ out.append(header)
245
+ if info["description"]:
246
+ out.append(f" {info['description'][:96]}")
247
+ if info["release"]:
248
+ out.append(f" latest release {info['release']['tag']}"
249
+ f" ({info['release']['date'][:10]})")
250
+ else:
251
+ out.append(" latest release none published")
252
+ if info["commit"]:
253
+ out.append(f" latest commit {info['commit']['sha']}"
254
+ f" ({info['commit']['date'][:10]}) {info['commit']['message']}")
255
+ return out
fxcss/probe.py ADDED
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env python3
2
+ """Identify browser UI elements and the rules that style them.
3
+
4
+ Two ways in:
5
+
6
+ * `pick` overlays a highlighter on the running browser -- hover any part of the
7
+ UI and click to get a selector you can paste into your theme.
8
+ * `inspect` takes a selector you already have and reports what it matches.
9
+
10
+ Both finish by grepping the theme for rules that target the element, so the
11
+ answer is not just "what is this" but "where do I edit it".
12
+
13
+ Named probe.py rather than inspect.py so it cannot shadow the standard
14
+ library's inspect module for the sibling imports.
15
+ """
16
+
17
+ import time
18
+
19
+ # Builds a selector from an element. Prefers an id, because userChrome rules
20
+ # overwhelmingly key off ids, then falls back to tag + classes, then to a short
21
+ # ancestor path so a nameless element is still addressable.
22
+ SELECTOR_HELPERS = """
23
+ const esc = (s) => (typeof CSS !== "undefined" && CSS.escape) ? CSS.escape(s) : s;
24
+ function classListOf(el) {
25
+ const c = el.getAttribute && el.getAttribute("class");
26
+ return c ? c.trim().split(/\\s+/).filter(Boolean) : [];
27
+ }
28
+ function selOf(el) {
29
+ if (!el || el.nodeType !== 1) return "";
30
+ if (el.id) return "#" + esc(el.id);
31
+ let s = el.localName;
32
+ const cls = classListOf(el);
33
+ if (cls.length) s += "." + cls.map(esc).join(".");
34
+ return s;
35
+ }
36
+ function pathOf(el) {
37
+ const parts = [];
38
+ let e = el;
39
+ while (e && e.nodeType === 1 && parts.length < 6) {
40
+ parts.unshift(selOf(e));
41
+ if (e.id) break;
42
+ e = e.parentElement;
43
+ }
44
+ return parts.join(" ");
45
+ }
46
+ function describe(el) {
47
+ const r = el.getBoundingClientRect();
48
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
49
+ const cs = win.getComputedStyle(el);
50
+ const styles = {};
51
+ for (const p of ["background-color","color","border-radius","box-shadow",
52
+ "list-style-image","font-size","padding","margin","opacity",
53
+ "height","width","display"]) {
54
+ const v = cs.getPropertyValue(p);
55
+ if (v && !["none","normal","auto","0px","rgba(0, 0, 0, 0)","1"].includes(v)) {
56
+ styles[p] = v;
57
+ }
58
+ }
59
+ return {
60
+ selector: selOf(el), path: pathOf(el),
61
+ tag: el.localName, id: el.id || null,
62
+ classes: classListOf(el),
63
+ attrs: [...el.attributes].map(a => a.name).filter(n => n !== "class" && n !== "id"),
64
+ rect: {x: Math.round(r.x), y: Math.round(r.y),
65
+ w: Math.round(r.width), h: Math.round(r.height)},
66
+ styles,
67
+ };
68
+ }
69
+ """
70
+
71
+ START_PICKER = SELECTOR_HELPERS + """
72
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
73
+ const doc = win.document;
74
+ if (win._fxcssStopPicker) { win._fxcssStopPicker(); }
75
+ win._fxcssPicked = null;
76
+
77
+ const HTML = "http://www.w3.org/1999/xhtml";
78
+ const box = doc.createElementNS(HTML, "div");
79
+ box.style.cssText = "position:fixed;z-index:2147483647;pointer-events:none;" +
80
+ "border:2px solid #ff0080;background:rgba(255,0,128,.14);box-sizing:border-box;" +
81
+ "border-radius:3px;display:none;";
82
+ const tag = doc.createElementNS(HTML, "div");
83
+ tag.style.cssText = "position:fixed;z-index:2147483647;pointer-events:none;" +
84
+ "background:#ff0080;color:#fff;padding:2px 6px;border-radius:3px;display:none;" +
85
+ "font:11px ui-monospace,Menlo,monospace;white-space:nowrap;";
86
+ doc.documentElement.appendChild(box);
87
+ doc.documentElement.appendChild(tag);
88
+
89
+ let current = null;
90
+ function onMove(ev) {
91
+ const el = ev.originalTarget || ev.target;
92
+ if (!el || el.nodeType !== 1 || el === box || el === tag) return;
93
+ current = el;
94
+ const r = el.getBoundingClientRect();
95
+ box.style.display = "block";
96
+ box.style.left = r.left + "px"; box.style.top = r.top + "px";
97
+ box.style.width = r.width + "px"; box.style.height = r.height + "px";
98
+ tag.style.display = "block";
99
+ tag.textContent = selOf(el);
100
+ tag.style.left = Math.max(0, r.left) + "px";
101
+ tag.style.top = Math.max(0, r.top - 19) + "px";
102
+ }
103
+ function onPick(ev) {
104
+ if (!current) return;
105
+ ev.preventDefault(); ev.stopPropagation();
106
+ win._fxcssPicked = describe(current);
107
+ }
108
+ function onKey(ev) {
109
+ if (ev.key === "Escape") { ev.preventDefault(); win._fxcssStopPicker(); }
110
+ }
111
+
112
+ // Capture phase so a click lands on the highlighter rather than activating the
113
+ // button underneath it.
114
+ doc.addEventListener("mousemove", onMove, true);
115
+ doc.addEventListener("mousedown", onPick, true);
116
+ doc.addEventListener("click", onPick, true);
117
+ doc.addEventListener("keydown", onKey, true);
118
+
119
+ win._fxcssStopPicker = function () {
120
+ doc.removeEventListener("mousemove", onMove, true);
121
+ doc.removeEventListener("mousedown", onPick, true);
122
+ doc.removeEventListener("click", onPick, true);
123
+ doc.removeEventListener("keydown", onKey, true);
124
+ box.remove(); tag.remove();
125
+ win._fxcssStopPicker = null;
126
+ win._fxcssPickerStopped = true;
127
+ };
128
+ win._fxcssPickerStopped = false;
129
+ return true;
130
+ """
131
+
132
+ READ_PICK = """
133
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
134
+ const p = win._fxcssPicked;
135
+ win._fxcssPicked = null;
136
+ return {picked: p, stopped: !!win._fxcssPickerStopped};
137
+ """
138
+
139
+ STOP_PICKER = """
140
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
141
+ if (win._fxcssStopPicker) { win._fxcssStopPicker(); }
142
+ return true;
143
+ """
144
+
145
+ QUERY = SELECTOR_HELPERS + """
146
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
147
+ const doc = win.document;
148
+ let nodes = [];
149
+ try { nodes = [...doc.querySelectorAll(arguments[0])]; }
150
+ catch (e) { return {error: String(e)}; }
151
+ return {count: nodes.length, matches: nodes.slice(0, 8).map(describe)};
152
+ """
153
+
154
+
155
+ def _print_element(info, repo, references):
156
+ print(f"\n {info['tag']} → {info['selector']}")
157
+ if info["path"] and info["path"] != info["selector"]:
158
+ print(f" path {info['path']}")
159
+ if info["classes"]:
160
+ print(f" classes {' '.join(info['classes'])}")
161
+ if info["attrs"]:
162
+ print(f" attrs {' '.join(info['attrs'][:10])}")
163
+ r = info["rect"]
164
+ print(f" box {r['w']}×{r['h']} at ({r['x']}, {r['y']})"
165
+ + (" (not visible in this layout)" if not r["w"] or not r["h"] else ""))
166
+ if info["styles"]:
167
+ print(" styles")
168
+ for k, v in info["styles"].items():
169
+ print(f" {k}: {v}")
170
+
171
+ hits = references(repo, info["selector"])
172
+ if hits:
173
+ print(f" styled by {len(hits)} rule{'s' if len(hits) != 1 else ''} in this theme")
174
+ for h in hits[:8]:
175
+ print(f" {h['file']}:{h['line']} {h['text'][:88]}")
176
+ if len(hits) > 8:
177
+ print(f" … and {len(hits) - 8} more")
178
+ else:
179
+ print(" styled by no rules in this theme (Firefox defaults only)")
180
+
181
+
182
+ def inspect_selector(session, selector, repo, references):
183
+ result = session.m.script(QUERY, [selector])
184
+ if isinstance(result, dict) and result.get("error"):
185
+ print(f"invalid selector: {result['error']}")
186
+ return 2
187
+ count = result["count"]
188
+ if not count:
189
+ print(f"no elements match {selector!r} in this Firefox")
190
+ return 1
191
+ print(f"{count} element{'s' if count != 1 else ''} match {selector!r}"
192
+ + (" (showing first 8)" if count > 8 else ""))
193
+ for info in result["matches"]:
194
+ _print_element(info, repo, references)
195
+ return 0
196
+
197
+
198
+ def pick(session, repo, references):
199
+ session.m.script(START_PICKER)
200
+ print("\n Move the mouse over the browser window; the element under the")
201
+ print(" cursor is outlined. Click to capture it. Esc in the browser, or")
202
+ print(" Ctrl-C here, to stop.\n")
203
+ picked = 0
204
+ try:
205
+ while True:
206
+ time.sleep(0.35)
207
+ state = session.m.script(READ_PICK)
208
+ if state.get("picked"):
209
+ picked += 1
210
+ _print_element(state["picked"], repo, references)
211
+ if state.get("stopped"):
212
+ print("\n picker stopped.")
213
+ break
214
+ except KeyboardInterrupt:
215
+ print("\n stopping.")
216
+ try:
217
+ session.m.script(STOP_PICKER)
218
+ except Exception:
219
+ pass
220
+ return 0 if picked else 0