fmind 2.0.0__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.
- {fmind-2.0.0 → fmind-2.0.2}/PKG-INFO +4 -4
- {fmind-2.0.0 → fmind-2.0.2}/README.md +3 -3
- {fmind-2.0.0 → fmind-2.0.2}/pyproject.toml +1 -1
- {fmind-2.0.0 → fmind-2.0.2}/pyproject.toml.orig +1 -1
- {fmind-2.0.0 → fmind-2.0.2}/src/fmind/api.py +58 -4
- {fmind-2.0.0 → fmind-2.0.2}/src/fmind/cli.py +14 -4
- {fmind-2.0.0 → fmind-2.0.2}/src/fmind/__init__.py +0 -0
- {fmind-2.0.0 → fmind-2.0.2}/src/fmind/__main__.py +0 -0
- {fmind-2.0.0 → fmind-2.0.2}/src/fmind/articles.py +0 -0
- {fmind-2.0.0 → fmind-2.0.2}/src/fmind/mcp.py +0 -0
- {fmind-2.0.0 → fmind-2.0.2}/src/fmind/render.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fmind
|
|
3
|
-
Version: 2.0.
|
|
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')"
|
|
@@ -136,7 +136,7 @@ For a host that accepts JSON configuration:
|
|
|
136
136
|
}
|
|
137
137
|
```
|
|
138
138
|
|
|
139
|
-
`fmind mcp` connects to `https://www.fmind.dev/mcp` using Streamable HTTP and forwards the website's tools, resources, and prompts over stdio. The website owns their names, schemas, and content. Set `FMIND_PROFILE_URL` in the host's environment to use `/mcp` on another website origin. No local data or response cache is used. Requests have a 15-second deadline and remote HTTP responses are limited to 8 MiB.
|
|
139
|
+
`fmind mcp` connects to `https://www.fmind.dev/mcp` using Streamable HTTP and forwards the website's tools, resources, and prompts over stdio. The website owns their names, schemas, and content. Set `FMIND_PROFILE_URL` in the host's environment to use `/mcp` on another website origin. No local data or response cache is used. The website answers MCP POST requests with JSON; GET/HEAD return `405` with `Allow: POST` because notification streams are not offered. Use `/health` for uptime probes. Requests have a 15-second deadline and remote HTTP responses are limited to 8 MiB.
|
|
140
140
|
|
|
141
141
|
Stdout carries only MCP messages; startup errors go to stderr. Portfolio output flags such as `--json` do not apply to `mcp`. The bridge forwards read-only request/response operations; subscriptions and server-initiated sampling or elicitation are not advertised.
|
|
142
142
|
|
|
@@ -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')"
|
|
@@ -112,7 +112,7 @@ For a host that accepts JSON configuration:
|
|
|
112
112
|
}
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
-
`fmind mcp` connects to `https://www.fmind.dev/mcp` using Streamable HTTP and forwards the website's tools, resources, and prompts over stdio. The website owns their names, schemas, and content. Set `FMIND_PROFILE_URL` in the host's environment to use `/mcp` on another website origin. No local data or response cache is used. Requests have a 15-second deadline and remote HTTP responses are limited to 8 MiB.
|
|
115
|
+
`fmind mcp` connects to `https://www.fmind.dev/mcp` using Streamable HTTP and forwards the website's tools, resources, and prompts over stdio. The website owns their names, schemas, and content. Set `FMIND_PROFILE_URL` in the host's environment to use `/mcp` on another website origin. No local data or response cache is used. The website answers MCP POST requests with JSON; GET/HEAD return `405` with `Allow: POST` because notification streams are not offered. Use `/health` for uptime probes. Requests have a 15-second deadline and remote HTTP responses are limited to 8 MiB.
|
|
116
116
|
|
|
117
117
|
Stdout carries only MCP messages; startup errors go to stderr. Portfolio output flags such as `--json` do not apply to `mcp`. The bridge forwards read-only request/response operations; subscriptions and server-initiated sampling or elicitation are not advertised.
|
|
118
118
|
|
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
out.
|
|
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
|