python-arch-wiki 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.
@@ -0,0 +1,468 @@
1
+ import logging
2
+ import os
3
+ import re
4
+ import sys
5
+ import time
6
+ from collections.abc import Iterator
7
+
8
+ import requests
9
+ from bs4 import BeautifulSoup
10
+ from bs4.element import Tag
11
+ from rich import box
12
+ from rich.console import Console
13
+ from rich.markup import escape
14
+ from rich.table import Table
15
+
16
+ # use Console() for paging, set default text color
17
+ console = Console(style="color(248)")
18
+ # set less as the pager
19
+ os.environ["MANPAGER"] = "less --raw-control-chars --mouse"
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class Toc:
25
+ """Arch wiki table of contents."""
26
+
27
+ root_url = "https://wiki.archlinux.org"
28
+ toc_session = requests.Session()
29
+ link_url: bool | None = None
30
+
31
+ Section = tuple[int, ...] | None
32
+ TocItem = tuple[Section, str, str, int]
33
+ MenuItem = tuple[Section, str]
34
+ Article = tuple[str, str]
35
+
36
+ section: Section
37
+ title: str
38
+ href: str
39
+ articles: list[Article]
40
+ num_articles: int
41
+ subsections: list["Toc"]
42
+ folded: bool
43
+
44
+ def __init__(
45
+ self,
46
+ section: Tag | TocItem | None = None,
47
+ folded=True,
48
+ link_url=False,
49
+ ) -> None:
50
+ """Initialize Toc section."""
51
+ if type(self).link_url is None:
52
+ type(self).link_url = link_url
53
+
54
+ self.subsections = []
55
+ self.articles = []
56
+
57
+ if section is None:
58
+ for i in range(1, 4):
59
+ try:
60
+ r = self.toc_session.get(
61
+ self._url_from_parts(
62
+ [self.root_url, "/title/Table_of_contents"]
63
+ ),
64
+ timeout=2,
65
+ )
66
+ r.raise_for_status()
67
+
68
+ soup = BeautifulSoup(r.text, "lxml")
69
+ rows = soup.select("#wiki-scripts-toc-table tr")
70
+ section = rows[0]
71
+ for row in rows[1:]:
72
+ self.add_subsection(row, folded=folded)
73
+
74
+ break
75
+
76
+ except requests.exceptions.ConnectionError as e:
77
+ time.sleep(0.5 * i)
78
+ logger.warning(f"Connection reset: {i=}: {e}")
79
+ continue
80
+
81
+ except requests.exceptions.RequestException as e:
82
+ logger.error(f"{e}")
83
+ sys.exit(f"{e}")
84
+
85
+ if isinstance(section, tuple):
86
+ self.section, self.title, self.href, self.num_articles = section
87
+ elif isinstance(section, Tag):
88
+ self.section, self.title, self.href, self.num_articles = (
89
+ self.parse_tag(section)
90
+ )
91
+
92
+ self.folded = False if self.section is None else folded
93
+
94
+ def close(self) -> None:
95
+ """Close session."""
96
+ self.toc_session.close()
97
+
98
+ def add_subsection(self, tag: Tag, folded: bool = False) -> None:
99
+ """Add a subsection to table of contents."""
100
+ subsection = self.parse_tag(tag)
101
+ assert subsection[0], "Can not add subsection without section"
102
+
103
+ # create a nested structure using section as a tuple of indices
104
+ parent = self
105
+ for i in subsection[0][:-1]:
106
+ parent = parent.subsections[i - 1]
107
+
108
+ parent.subsections.append(Toc(subsection, folded=folded))
109
+
110
+ def parse_tag(self, trtag: Tag) -> TocItem:
111
+ """Extract section info from tr tag into a tuple."""
112
+ a = trtag.find("a") if trtag else None
113
+ if a is None:
114
+ raise ValueError("'a' tag not found")
115
+
116
+ title = a.get_text()
117
+ href = str(a["href"])
118
+
119
+ try:
120
+ section = tuple(
121
+ int(x)
122
+ for x in a.find_previous_sibling("small")
123
+ .get_text() # type: ignore
124
+ .strip(".")
125
+ .split(".")
126
+ )
127
+
128
+ except (AttributeError, ValueError):
129
+ section = None
130
+
131
+ try:
132
+ num_articles = int(
133
+ a.find_next_sibling("small")
134
+ .get_text() # type: ignore
135
+ .strip("()")
136
+ )
137
+
138
+ except (AttributeError, ValueError):
139
+ num_articles = 0
140
+
141
+ if not title or not href:
142
+ raise ValueError(f"Failed to parse tag: {a}")
143
+ else:
144
+ return section, title, href, num_articles
145
+
146
+ def get_entries(
147
+ self,
148
+ section: "Toc | None" = None,
149
+ toc_list: list[MenuItem] | None = None,
150
+ ) -> list[MenuItem]:
151
+ """Return a list of section/title tuples."""
152
+ if section is None:
153
+ section = self
154
+ if toc_list is None:
155
+ toc_list = []
156
+
157
+ if section.section:
158
+ section_str = (
159
+ f"{' ' * 4 * (len(section.section) - 1)}"
160
+ f"{section.section[-1]:>2}. "
161
+ )
162
+
163
+ if section.num_articles:
164
+ num_art_str = f" ({section.num_articles})"
165
+ else:
166
+ num_art_str = ""
167
+
168
+ if section.folded and section.subsections:
169
+ subsect_str = f" *{len(section.subsections)}"
170
+ else:
171
+ subsect_str = ""
172
+
173
+ toc_list.append(
174
+ (
175
+ section.section,
176
+ f"{section_str}{section.title}{num_art_str}{subsect_str}",
177
+ )
178
+ )
179
+
180
+ if section.subsections and not section.folded:
181
+ for subsection in section.subsections:
182
+ self.get_entries(subsection, toc_list)
183
+
184
+ return toc_list
185
+
186
+ def __iter__(self) -> Iterator[MenuItem]:
187
+ """Return Toc iterator."""
188
+ return iter(self.get_entries())
189
+
190
+ def fold(self, section: Section) -> None:
191
+ """Toggle displaying subsections."""
192
+
193
+ if isinstance(section, tuple):
194
+ subsection = self
195
+ for i in section:
196
+ subsection = subsection.subsections[i - 1]
197
+ subsection.folded = not subsection.folded
198
+
199
+ @staticmethod
200
+ def _url_from_parts(parts: list) -> str:
201
+ return "/".join(part.strip("/") for part in parts)
202
+
203
+ def get_submenu(self, section: Section) -> list[Article]:
204
+ """Return list of articles."""
205
+ assert section, "Valid section required"
206
+ subsection = self
207
+ for i in section:
208
+ subsection = subsection.subsections[i - 1]
209
+
210
+ if not subsection.articles:
211
+ try:
212
+ with self.toc_session.get(
213
+ self._url_from_parts([self.root_url, subsection.href]),
214
+ timeout=2,
215
+ ) as r:
216
+ r.raise_for_status()
217
+
218
+ soup = BeautifulSoup(r.text, "lxml")
219
+ categories = soup.select_one(".mw-category")
220
+ if categories:
221
+ for a in categories.select("a"):
222
+ subsection.articles.append(
223
+ (str(a["href"]), str(a["title"]))
224
+ )
225
+
226
+ except requests.exceptions.RequestException as e:
227
+ self.close()
228
+ logger.error(f"{e}")
229
+ sys.exit(f"{e}")
230
+
231
+ return subsection.articles
232
+
233
+ @staticmethod
234
+ def _console_print(text: str) -> None:
235
+ """Add escape sequences and print."""
236
+ text = re.sub( # Command highlight
237
+ r"""
238
+ (^|:\s) # 1) start of the line or colon space
239
+ ([#\$]) # 2) hash or dollar
240
+ (\s[a-z]\S*) # 3) space, letter, one or more of not a space
241
+ (.*) # 4) rest of the line
242
+ """,
243
+ r"\1[bold][green]\2[/][bright_white]\3[/]\4[/]",
244
+ text,
245
+ flags=re.MULTILINE | re.VERBOSE,
246
+ )
247
+ text = re.sub( # Option highlight
248
+ r"(\s-[\w-]+\b)",
249
+ r"[bold color(103)]\1[/]",
250
+ text,
251
+ )
252
+ console.print(text)
253
+
254
+ @staticmethod
255
+ def get_table_rows(tr_list: list[Tag]) -> list[list[str]] | None:
256
+ """Return a list of lists (rows) of strings (cols)."""
257
+ nrows = len(tr_list)
258
+ if nrows == 0:
259
+ return None
260
+
261
+ rows = [[] for _ in range(nrows)]
262
+ rgx = re.compile("t[dh]")
263
+
264
+ for col in tr_list[0](rgx):
265
+ colspan = col.get("colspan")
266
+ rowspan = col.get("rowspan")
267
+ coltext = col.get_text()
268
+ rowspan = rowspan if rowspan is None else int(str(rowspan)) - 1
269
+
270
+ if colspan is None:
271
+ rows[0].append(coltext)
272
+ for row in rows[1:]:
273
+ if rowspan:
274
+ row.append("")
275
+ rowspan -= 1
276
+ else:
277
+ row.append(None)
278
+ else:
279
+ for _ in range(int(str(colspan))):
280
+ rows[0].append(f"[underline]{coltext}[/]")
281
+ for row in rows[1:]:
282
+ if rowspan:
283
+ row.append("")
284
+ rowspan -= 1
285
+ else:
286
+ row.append(None)
287
+
288
+ for i, row in enumerate(tr_list[1:]):
289
+ for j, col in enumerate(row(rgx)):
290
+ colspan = col.get("colspan")
291
+ rowspan = col.get("rowspan")
292
+ coltext = col.get_text()
293
+ rowspan = 1 if rowspan is None else int(str(rowspan))
294
+
295
+ while rows[i + 1][j] is not None:
296
+ j += 1
297
+
298
+ if colspan is None:
299
+ for n in range(rowspan):
300
+ rows[i + 1 + n][j] = coltext if n == 0 else ""
301
+ else:
302
+ j -= 1
303
+ for _ in range(int(str(colspan))):
304
+ j += 1
305
+ for n in range(rowspan):
306
+ rows[i + 1 + n][j] = (
307
+ f"[underline]{coltext}[/]" if n == 0 else ""
308
+ )
309
+
310
+ return rows
311
+
312
+ @classmethod
313
+ def _parse_section(cls, section: Tag | None) -> None:
314
+ """Extract text from tags and apply formatting."""
315
+ if section is None:
316
+ return
317
+
318
+ for tag in section.children: # type: ignore
319
+ tag: Tag
320
+ if tag.name is None:
321
+ continue
322
+
323
+ for code in tag("code"):
324
+ code.name = "p"
325
+ code.string = f"[bold white]{code.get_text()}[/]"
326
+
327
+ for a in tag("a"):
328
+ href = str(a.get("href"))
329
+ text = a.string
330
+
331
+ if text and href != text:
332
+ a.name = "p"
333
+ if href.startswith("/title"):
334
+ href = cls._url_from_parts([cls.root_url, href])
335
+
336
+ # do we follow link with url?
337
+ text_len = len(text)
338
+ href_len = len(href)
339
+ if cls.link_url and href.startswith("http"):
340
+ text += rf" \[[#515478]{href}[/]]"
341
+ if text_len + href_len < console.width:
342
+ text = text.replace(" ", "[#515478]_[/]")
343
+
344
+ a.string = f"[bold color(251)]{text}[/]"
345
+
346
+ if "mw-heading" in tag.get_attribute_list("class"):
347
+ console.rule(
348
+ f"[white]{tag.get_text()}[/]", style="bright_black"
349
+ )
350
+
351
+ elif "archwiki-template-box" in tag.get_attribute_list("class"):
352
+ strong: Tag | str | None = tag.find("strong")
353
+ if strong:
354
+ strong = strong.extract().get_text()
355
+ color = "bold"
356
+
357
+ match strong.lower():
358
+ case "tip":
359
+ color += " green"
360
+ case "note":
361
+ color += " blue"
362
+ case "warning":
363
+ color += " yellow"
364
+
365
+ cls._console_print(
366
+ f"[{color}]{strong}:[/]{tag.get_text()}\n"
367
+ )
368
+ else:
369
+ cls._console_print(tag.get_text())
370
+
371
+ elif "archwiki-template-message" in tag.get_attribute_list(
372
+ "class"
373
+ ):
374
+ cls._console_print(tag.get_text())
375
+
376
+ elif "mw-hidden-catlinks" in tag.get_attribute_list("class"):
377
+ continue
378
+
379
+ elif tag.name == "div":
380
+ cls._parse_section(tag)
381
+
382
+ elif tag.name == "table":
383
+ caption = tag.find("caption")
384
+ title = caption.get_text() if caption else None
385
+ table = Table(
386
+ title=title,
387
+ box=box.ROUNDED,
388
+ highlight=True,
389
+ show_lines=True,
390
+ )
391
+
392
+ rows = cls.get_table_rows(tag("tr"))
393
+ if not rows:
394
+ logger.warning(f"Empty table: {tag}")
395
+ continue
396
+
397
+ for col in rows[0]:
398
+ table.add_column(col)
399
+ for row in rows[1:]:
400
+ table.add_row(*row)
401
+
402
+ console.print(table)
403
+
404
+ else:
405
+ cls._console_print(tag.get_text())
406
+
407
+ @classmethod
408
+ def display_contents(cls, href: str | tuple[str]) -> None:
409
+ """Display article."""
410
+ if isinstance(href, tuple):
411
+ href = "/title/" + "_".join(href)
412
+ try:
413
+ with cls.toc_session.get(
414
+ cls._url_from_parts([cls.root_url, href]),
415
+ timeout=1,
416
+ ) as r:
417
+ r.raise_for_status()
418
+ soup = BeautifulSoup(escape(r.text), "lxml")
419
+
420
+ except requests.exceptions.RequestException as e:
421
+ cls.toc_session.close()
422
+ logger.error(f"{e}")
423
+ sys.exit(f"{e}")
424
+
425
+ with console.pager(styles=True):
426
+ cls._parse_section(soup.select_one("#bodyContent"))
427
+
428
+ def search(self, text: str) -> list[Article]:
429
+ """Search arch wiki."""
430
+ search_text = text.strip().replace(" ", "+")
431
+ payload = {
432
+ "search": search_text,
433
+ "title": "Special%3ASearch",
434
+ "profile": "default",
435
+ "fulltext": "1",
436
+ }
437
+ results = []
438
+
439
+ try:
440
+ with self.toc_session.get(
441
+ self._url_from_parts([self.root_url, "index.php"]),
442
+ params=payload,
443
+ timeout=1,
444
+ ) as r:
445
+ r.raise_for_status()
446
+ soup = BeautifulSoup(r.text, "lxml")
447
+
448
+ except requests.exceptions.RequestException as e:
449
+ logger.error(f"{e}")
450
+
451
+ else:
452
+ for tag in soup.select(".mw-search-result"):
453
+ heading = tag.find(class_="mw-search-result-heading")
454
+ if heading and (a := heading.find("a")):
455
+ results.append((str(a["href"]), str(a["title"])))
456
+
457
+ return results
458
+
459
+
460
+ def main() -> None:
461
+ toc = Toc(folded=False)
462
+
463
+ print(toc.get_submenu((1, 1)))
464
+ toc.close()
465
+
466
+
467
+ if __name__ == "__main__":
468
+ main()
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-arch-wiki
3
+ Version: 0.1.0
4
+ Summary: A terminal interface for browsing the Arch Linux Wiki
5
+ Author: Anton Kaplan
6
+ Author-email: Anton Kaplan <anton.kaplan@live.ca>
7
+ License-Expression: GPL-3.0-or-later
8
+ License-File: LICENSE
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Dist: autocommand>=2.2.2
12
+ Requires-Dist: bs4>=0.0.2
13
+ Requires-Dist: lxml>=6.0.2
14
+ Requires-Dist: platformdirs>=4.11.0
15
+ Requires-Dist: requests>=2.32.5
16
+ Requires-Dist: rich>=14.2.0
17
+ Requires-Python: >=3.13
18
+ Project-URL: Homepage, https://github.com/termctrlseq/python-arch-wiki
19
+ Project-URL: Issues, https://github.com/termctrlseq/python-arch-wiki/issues
20
+ Description-Content-Type: text/markdown
21
+
22
+ # python-arch-wiki
23
+
24
+ A terminal interface for browsing the [Arch Linux Wiki](https://wiki.archlinux.org/), with an interactive table of contents and article search.
25
+
26
+ ## Installation
27
+
28
+ To install in the virtual environment in the directory `python_arch_wiki` and create a link with a name *wiki* (assuming `~/.local/bin` is in the **PATH**):
29
+ ```bash
30
+ mkdir python_arch_wiki
31
+ cd python_arch_wiki
32
+ python3 -m venv venv
33
+ venv/bin/python -m pip install \
34
+ -e 'python-arch-wiki @ git+https://github.com/termctrlseq/python-arch-wiki.git'
35
+ ln -s "$PWD/venv/bin/python-arch-wiki" "$HOME/.local/bin/wiki"
36
+ ```
37
+
38
+ To add `~/.local/bin` to **PATH** put this in your `~/.bashrc`
39
+ ```bash
40
+ # add ~/.local/bin to PATH if not in it
41
+ [[ ":${PATH}:" != *:"${HOME}/.local/bin":* ]] \
42
+ && export PATH="${HOME}/.local/bin:${PATH}"
43
+ ```
44
+
45
+ Then the source code can be found in `~/python_arch_wiki/venv/src/`.
46
+
47
+ ## Usage
48
+
49
+ ```
50
+ wiki [-l] [-v] [article name]
51
+ ```
52
+
53
+ ## Options
54
+
55
+ ```text
56
+ -l, --link-url Show URLs in links
57
+ -v, --verbose Enable debug logging
58
+ ```
59
+
60
+ ## Examples
61
+
62
+ Open the table of contents:
63
+
64
+ ```bash
65
+ wiki
66
+ ```
67
+
68
+ From there, press `/` to search the Arch Wiki. The search uses the Arch Wiki's own search engine, and the results are presented in a navigable menu. For convenience, this can be preferable to specifying a longer query on the command line:
69
+
70
+ ```text
71
+ /install
72
+ ```
73
+
74
+ For short, specific searches, giving the article name on the command line is often more convenient:
75
+
76
+ ```bash
77
+ wiki zram
78
+ ```
79
+
80
+ ## Controls
81
+
82
+ | Key | Action |
83
+ | ---------------------------------- | ------------------- |
84
+ | `↑` / `k` / `Ctrl-P` / `Shift-Tab` | Previous |
85
+ | `↓` / `j` / `Ctrl-N` / `Tab` | Next |
86
+ | `H` | First visible item |
87
+ | `M` | Middle visible item |
88
+ | `L` | Last visible item |
89
+ | `Enter` | Open |
90
+ | `Space` / `l` | Fold/unfold |
91
+ | `h` / `u` / `Esc` | Go up |
92
+ | `/` / `?` | Search |
93
+ | `q` / `Ctrl-D` | Quit |
94
+
95
+ Mouse input is also supported:
96
+ | Button | Action |
97
+ | ------------------- | ----------- |
98
+ | *Left click* | select |
99
+ | *Double left click* | open |
100
+ | *Right click* | fold/unfold |
101
+ | *Wheel* | navigate |
102
+
103
+ Articles are rendered in the terminal and paged with `less`.
104
+
105
+ ## Requirements
106
+
107
+ * Python 3.13+
108
+ * A terminal with `curses` support
109
+ * Internet connection
@@ -0,0 +1,9 @@
1
+ python_arch_wiki/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ python_arch_wiki/__main__.py,sha256=Koj2bOv0fnNW9BFQ6dhs1kXhr5ZY9bLt1-aggJv1VX0,1114
3
+ python_arch_wiki/menu.py,sha256=sjtfLww7OcFPpdniGyoWmof_Eu94NRKJiRCf5hWhUIM,10075
4
+ python_arch_wiki/toc.py,sha256=LL7O0k9Cw3f3w5bKS572w9drhAzQd-xtpBwZt9LjhWE,14999
5
+ python_arch_wiki-0.1.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
6
+ python_arch_wiki-0.1.0.dist-info/WHEEL,sha256=Kot-FOXwz2no6rGIg4et79Z7m6vcKPAXr2Y6fTFpZuI,81
7
+ python_arch_wiki-0.1.0.dist-info/entry_points.txt,sha256=JgtU2VxggJtVjpkcj8GxmpTNzPqD0a4wcjxqqWfMCXs,69
8
+ python_arch_wiki-0.1.0.dist-info/METADATA,sha256=U9ew55rU3XjNjUhd4lxl5IcMbkuS63kVuZ0x3iQoeG0,3378
9
+ python_arch_wiki-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.10
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ python-arch-wiki = python_arch_wiki.__main__:main
3
+