fmind 2.0.1__tar.gz → 2.0.2__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fmind
3
- Version: 2.0.1
3
+ Version: 2.0.2
4
4
  Summary: Read Médéric Hurier's (Fmind) portfolio from the terminal.
5
5
  Keywords: portfolio,cli,fmind,resume,articles
6
6
  Author: Médéric Hurier
@@ -36,7 +36,7 @@ Every command renders a document the website already publishes — the portfolio
36
36
 
37
37
  This package carries no copy of the portfolio and needs **no release for content updates**: new articles, a renewed certification or a new engagement appear on the next run. Removing or changing fields that the CLI renders can require a client update; the live compatibility workflow detects those breaks.
38
38
 
39
- Every command fetches from the website and asks HTTP caches to revalidate. There is no local cache and no `--refresh` option. An internet connection is required: if the website cannot be read, the command reports an error instead of showing stale information.
39
+ Every command fetches from the website and asks HTTP caches to revalidate. There is no local cache and no `--refresh` option. Each portfolio download has a 15-second total deadline, including connection setup and response reading, and an 8 MiB response limit. An internet connection is required: if the website cannot be read, the command reports an error instead of showing stale information.
40
40
 
41
41
  ## Commands
42
42
 
@@ -74,7 +74,7 @@ fmind search "agent security"
74
74
  fmind search --tag MLOps --limit 3
75
75
  ```
76
76
 
77
- `fmind read` takes a slug, or enough of one to be unambiguous, and prints the article. In an interactive terminal it pages through `$PAGER` (default `less`, with `LESS=FRX` unless you set `LESS`), so short articles print directly; `PAGER=cat` disables paging. Use `--raw` for the Markdown source.
77
+ `fmind read` takes a slug, or enough of one to be unambiguous, and prints the article. In an interactive terminal it pages through `$PAGER` (default `less`, with `LESS=FRX` unless you set `LESS`), so short articles print directly; `PAGER=cat` disables paging. If the selected pager cannot start or exits unsuccessfully, the command reports an error; retry with `PAGER=cat`. Use `--raw` for the Markdown source.
78
78
 
79
79
  ```bash
80
80
  slug="$(fmind articles --limit 1 --json | jq -r '.[0].slug')"
@@ -12,7 +12,7 @@ Every command renders a document the website already publishes — the portfolio
12
12
 
13
13
  This package carries no copy of the portfolio and needs **no release for content updates**: new articles, a renewed certification or a new engagement appear on the next run. Removing or changing fields that the CLI renders can require a client update; the live compatibility workflow detects those breaks.
14
14
 
15
- Every command fetches from the website and asks HTTP caches to revalidate. There is no local cache and no `--refresh` option. An internet connection is required: if the website cannot be read, the command reports an error instead of showing stale information.
15
+ Every command fetches from the website and asks HTTP caches to revalidate. There is no local cache and no `--refresh` option. Each portfolio download has a 15-second total deadline, including connection setup and response reading, and an 8 MiB response limit. An internet connection is required: if the website cannot be read, the command reports an error instead of showing stale information.
16
16
 
17
17
  ## Commands
18
18
 
@@ -50,7 +50,7 @@ fmind search "agent security"
50
50
  fmind search --tag MLOps --limit 3
51
51
  ```
52
52
 
53
- `fmind read` takes a slug, or enough of one to be unambiguous, and prints the article. In an interactive terminal it pages through `$PAGER` (default `less`, with `LESS=FRX` unless you set `LESS`), so short articles print directly; `PAGER=cat` disables paging. Use `--raw` for the Markdown source.
53
+ `fmind read` takes a slug, or enough of one to be unambiguous, and prints the article. In an interactive terminal it pages through `$PAGER` (default `less`, with `LESS=FRX` unless you set `LESS`), so short articles print directly; `PAGER=cat` disables paging. If the selected pager cannot start or exits unsuccessfully, the command reports an error; retry with `PAGER=cat`. Use `--raw` for the Markdown source.
54
54
 
55
55
  ```bash
56
56
  slug="$(fmind articles --limit 1 --json | jq -r '.[0].slug')"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fmind"
3
- version = "2.0.1"
3
+ version = "2.0.2"
4
4
  description = "Read Médéric Hurier's (Fmind) portfolio from the terminal."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,7 +1,7 @@
1
1
  # https://packaging.python.org/en/latest/specifications/pyproject-toml/
2
2
  [project]
3
3
  name = "fmind"
4
- version = "2.0.1"
4
+ version = "2.0.2"
5
5
  description = "Read Médéric Hurier's (Fmind) portfolio from the terminal."
6
6
  readme = "README.md"
7
7
  license = "MIT"
@@ -5,7 +5,10 @@ from __future__ import annotations
5
5
  import http.client
6
6
  import json
7
7
  import os
8
+ import queue
8
9
  import re
10
+ import threading
11
+ import time
9
12
  import urllib.error
10
13
  import urllib.parse
11
14
  import urllib.request
@@ -80,27 +83,63 @@ def origin_url(path: str, *, profile_url: str | None = None) -> str:
80
83
 
81
84
 
82
85
  def _download(url: str, accept: str = "application/json") -> bytes:
83
- """Fetch a document over HTTP(S) with an explicit timeout and size bound."""
86
+ """Fetch a document within a wall-clock deadline, including DNS and headers."""
84
87
  _http_url(url)
88
+ deadline = time.monotonic() + TIMEOUT_SECONDS
89
+ result: queue.Queue[bytes | Exception] = queue.Queue(maxsize=1)
90
+
91
+ def fetch() -> None:
92
+ try:
93
+ result.put(_fetch(url, accept, deadline))
94
+ except Exception as error:
95
+ result.put(error)
96
+
97
+ # DNS and urllib's header reads cannot be cancelled portably. A daemon does
98
+ # not hold the CLI open after the deadline; body reads stop cooperatively.
99
+ threading.Thread(target=fetch, daemon=True).start()
100
+ try:
101
+ outcome = result.get(timeout=max(0.0, deadline - time.monotonic()))
102
+ except queue.Empty as error:
103
+ raise FmindError("the website request timed out; check your connection and FMIND_PROFILE_URL") from error
104
+ if isinstance(outcome, Exception):
105
+ raise outcome
106
+ return outcome
107
+
108
+
109
+ def _fetch(url: str, accept: str, deadline: float) -> bytes:
110
+ """Perform bounded I/O; never print or mutate caller state from the worker."""
85
111
  # Ask intermediary HTTP caches to revalidate too; no local copy is kept.
86
112
  headers = {"User-Agent": USER_AGENT, "Accept": accept, "Cache-Control": "no-cache"}
87
113
  try:
114
+ if time.monotonic() >= deadline:
115
+ raise TimeoutError("website request deadline exceeded")
88
116
  request = urllib.request.Request(url, headers=headers) # noqa: S310
89
117
  with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response: # noqa: S310
90
- payload = response.read(MAX_BYTES + 1)
118
+ payload = bytearray()
119
+ while len(payload) <= MAX_BYTES:
120
+ if time.monotonic() >= deadline:
121
+ raise TimeoutError("website request deadline exceeded")
122
+ # read() can wait indefinitely for a slowly progressing body.
123
+ # read1() returns after at most one underlying read.
124
+ chunk = response.read1(min(64 * 1024, MAX_BYTES + 1 - len(payload)))
125
+ if not chunk:
126
+ break
127
+ payload.extend(chunk)
91
128
  length = response.headers.get("Content-Length")
92
129
  if len(payload) <= MAX_BYTES and length is not None and len(payload) != int(length):
93
- raise http.client.IncompleteRead(payload)
130
+ raise http.client.IncompleteRead(bytes(payload))
94
131
  except urllib.error.HTTPError as error:
95
132
  msg = f"the website returned HTTP {error.code}"
96
133
  raise FmindError(msg) from error
134
+ except TimeoutError as error:
135
+ raise FmindError("the website request timed out; check your connection and FMIND_PROFILE_URL") from error
97
136
  except (urllib.error.URLError, OSError, http.client.HTTPException, ValueError) as error:
98
137
  msg = "could not read the website; check your connection and FMIND_PROFILE_URL"
99
138
  raise FmindError(msg) from error
100
139
  if len(payload) > MAX_BYTES:
101
140
  msg = f"the website returned more than {MAX_BYTES} bytes"
102
141
  raise FmindError(msg)
103
- return payload
142
+ return bytes(payload)
104
143
 
105
144
 
106
145
  def _validate(value: object, shape: object, path: str) -> None:
@@ -125,6 +164,21 @@ def _parse_profile(payload: bytes) -> dict[str, Any]:
125
164
  document = json.loads(payload)
126
165
  except (ValueError, RecursionError) as error:
127
166
  raise FmindError("the profile endpoint did not return JSON") from error
167
+ # Python's JSON decoder accepts lone surrogates. Check keys and future fields
168
+ # too: JSON output forwards them even when the renderer does not consume them.
169
+ pending = [document]
170
+ while pending:
171
+ value = pending.pop()
172
+ if isinstance(value, str):
173
+ try:
174
+ value.encode("utf-8")
175
+ except UnicodeEncodeError as error:
176
+ raise FmindError("the profile endpoint returned invalid Unicode") from error
177
+ elif isinstance(value, dict):
178
+ pending.extend(value)
179
+ pending.extend(value.values())
180
+ elif isinstance(value, list):
181
+ pending.extend(value)
128
182
  _validate(document, PROFILE_SHAPE, "profile")
129
183
  links = document["thesis"].get("links")
130
184
  if links is not None:
@@ -16,7 +16,7 @@ from rich.console import Console, RenderableType
16
16
  from rich.pager import Pager
17
17
 
18
18
  from fmind import __version__, articles, render
19
- from fmind.api import FmindError, load_article, load_profile
19
+ from fmind.api import CONTROL_PATTERN, FmindError, load_article, load_profile
20
20
 
21
21
  app = typer.Typer(
22
22
  name="fmind",
@@ -58,6 +58,8 @@ def main(
58
58
 
59
59
  def _fail(message: str) -> typer.Exit:
60
60
  """Report a clear one-line error on stderr and end the command."""
61
+ message = CONTROL_PATTERN.sub(lambda match: ascii(match.group())[1:-1], message)
62
+ message = message.encode("utf-8", errors="backslashreplace").decode("utf-8")
61
63
  typer.echo(f"fmind: {' '.join(message.split())}", err=True)
62
64
  return typer.Exit(code=1)
63
65
 
@@ -80,7 +82,12 @@ class _SystemPager(Pager):
80
82
  # Like Git: respect an explicit LESS, otherwise keep colours (-R), skip
81
83
  # paging for short output (-F), and leave the text on screen (-X).
82
84
  environment = {**os.environ, "LESS": os.environ.get("LESS", "FRX")}
83
- subprocess.run(self.command, input=content.encode(), env=environment, check=False) # noqa: S603
85
+ try:
86
+ subprocess.run( # noqa: S603
87
+ self.command, input=content.encode(), env=environment, stderr=subprocess.DEVNULL, check=True
88
+ )
89
+ except (OSError, subprocess.CalledProcessError) as error:
90
+ raise FmindError("could not display the article with PAGER; check PAGER or use PAGER=cat") from error
84
91
 
85
92
 
86
93
  def _pager_command() -> list[str] | None:
@@ -119,8 +126,11 @@ def _emit(ctx: typer.Context, body: RenderableType, payload: Any, *, as_json: bo
119
126
  if command is None:
120
127
  out.print(body, crop=False)
121
128
  return
122
- with out.pager(pager=_SystemPager(command), styles=True):
123
- out.print(body, crop=False)
129
+ try:
130
+ with out.pager(pager=_SystemPager(command), styles=True):
131
+ out.print(body, crop=False)
132
+ except FmindError as error:
133
+ raise _fail(str(error)) from error
124
134
 
125
135
 
126
136
  @app.command()
File without changes
File without changes
File without changes
File without changes
File without changes