fmind 2.0.1__tar.gz → 2.0.3__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.1 → fmind-2.0.3}/PKG-INFO +2 -26
- {fmind-2.0.1 → fmind-2.0.3}/README.md +1 -25
- {fmind-2.0.1 → fmind-2.0.3}/pyproject.toml +1 -1
- {fmind-2.0.1 → fmind-2.0.3}/pyproject.toml.orig +1 -1
- {fmind-2.0.1 → fmind-2.0.3}/src/fmind/api.py +64 -5
- {fmind-2.0.1 → fmind-2.0.3}/src/fmind/cli.py +18 -5
- {fmind-2.0.1 → fmind-2.0.3}/src/fmind/__init__.py +0 -0
- {fmind-2.0.1 → fmind-2.0.3}/src/fmind/__main__.py +0 -0
- {fmind-2.0.1 → fmind-2.0.3}/src/fmind/articles.py +0 -0
- {fmind-2.0.1 → fmind-2.0.3}/src/fmind/mcp.py +0 -0
- {fmind-2.0.1 → fmind-2.0.3}/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.3
|
|
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
|
|
@@ -30,14 +30,6 @@ Read [Médéric Hurier's (Fmind)](https://www.fmind.dev/) portfolio from the ter
|
|
|
30
30
|
uvx fmind whoami
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
## Why it stays small
|
|
34
|
-
|
|
35
|
-
Every command renders a document the website already publishes — the portfolio at [`/api/profile`](https://www.fmind.dev/api/profile) and, for `fmind read`, the Markdown source of an article at `/articles/<slug>.md`. Those are the same sources behind the site, the Atom feed, `llms.txt` and the MCP server.
|
|
36
|
-
|
|
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
|
-
|
|
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.
|
|
40
|
-
|
|
41
33
|
## Commands
|
|
42
34
|
|
|
43
35
|
| Command | Shows |
|
|
@@ -74,7 +66,7 @@ fmind search "agent security"
|
|
|
74
66
|
fmind search --tag MLOps --limit 3
|
|
75
67
|
```
|
|
76
68
|
|
|
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.
|
|
69
|
+
`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
70
|
|
|
79
71
|
```bash
|
|
80
72
|
slug="$(fmind articles --limit 1 --json | jq -r '.[0].slug')"
|
|
@@ -158,22 +150,6 @@ The pre-commit hook runs checks without changing or staging files; run `mise run
|
|
|
158
150
|
|
|
159
151
|
A release tag must match the version in `pyproject.toml` (for example, `v0.1.0`). CD runs the same full gate for that tag and checks the live commands before publishing through PyPI Trusted Publishing. The live smoke check is a release check, not a pull-request gate.
|
|
160
152
|
|
|
161
|
-
### Website compatibility
|
|
162
|
-
|
|
163
|
-
The **Website compatibility** GitHub Actions workflow runs `mise run smoke` daily at 06:23 UTC, on pushes to `main`, and on manual dispatch. Failed runs use normal GitHub Actions notifications; no issue bot or extra service is needed. The offline CI gate stays independent of website availability.
|
|
164
|
-
|
|
165
|
-
The same check exercises every portfolio command in text and both JSON option positions, reads the newest article as Markdown, and launches the real MCP stdio bridge. It discovers the remote tools, resources, templates, and prompts; calls every advertised read-only tool, including article reading with a live slug and the hosting calculator with its defaults; reads resources; and retrieves prompts. The SDK validates tool results against the website's current schemas, and the profile resource is checked against the CLI's rendering contract. API and MCP failures are reported independently. Checks use live data, not snapshots of portfolio facts.
|
|
166
|
-
|
|
167
|
-
After a website deployment, run `mise run smoke` locally or dispatch the workflow:
|
|
168
|
-
|
|
169
|
-
```bash
|
|
170
|
-
gh workflow run compatibility.yml --repo fmind/cli
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
A failure indicates either an outage or a contract change; inspect the failing operation in the run log. Keep `PROFILE_SHAPE` limited to consumed fields and update synthetic fixtures alongside rendering changes. New MCP tools with required inputs may need a sample argument in the smoke check. No release is needed merely to add content or an unused API field.
|
|
174
|
-
|
|
175
|
-
Commands exit with 0 on success, 1 for website or article-resolution failures, and 2 for invalid CLI usage.
|
|
176
|
-
|
|
177
153
|
## License
|
|
178
154
|
|
|
179
155
|
[MIT](LICENSE) — © 2026 Médéric Hurier (Fmind).
|
|
@@ -6,14 +6,6 @@ Read [Médéric Hurier's (Fmind)](https://www.fmind.dev/) portfolio from the ter
|
|
|
6
6
|
uvx fmind whoami
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
## Why it stays small
|
|
10
|
-
|
|
11
|
-
Every command renders a document the website already publishes — the portfolio at [`/api/profile`](https://www.fmind.dev/api/profile) and, for `fmind read`, the Markdown source of an article at `/articles/<slug>.md`. Those are the same sources behind the site, the Atom feed, `llms.txt` and the MCP server.
|
|
12
|
-
|
|
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
|
-
|
|
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.
|
|
16
|
-
|
|
17
9
|
## Commands
|
|
18
10
|
|
|
19
11
|
| Command | Shows |
|
|
@@ -50,7 +42,7 @@ fmind search "agent security"
|
|
|
50
42
|
fmind search --tag MLOps --limit 3
|
|
51
43
|
```
|
|
52
44
|
|
|
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.
|
|
45
|
+
`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
46
|
|
|
55
47
|
```bash
|
|
56
48
|
slug="$(fmind articles --limit 1 --json | jq -r '.[0].slug')"
|
|
@@ -134,22 +126,6 @@ The pre-commit hook runs checks without changing or staging files; run `mise run
|
|
|
134
126
|
|
|
135
127
|
A release tag must match the version in `pyproject.toml` (for example, `v0.1.0`). CD runs the same full gate for that tag and checks the live commands before publishing through PyPI Trusted Publishing. The live smoke check is a release check, not a pull-request gate.
|
|
136
128
|
|
|
137
|
-
### Website compatibility
|
|
138
|
-
|
|
139
|
-
The **Website compatibility** GitHub Actions workflow runs `mise run smoke` daily at 06:23 UTC, on pushes to `main`, and on manual dispatch. Failed runs use normal GitHub Actions notifications; no issue bot or extra service is needed. The offline CI gate stays independent of website availability.
|
|
140
|
-
|
|
141
|
-
The same check exercises every portfolio command in text and both JSON option positions, reads the newest article as Markdown, and launches the real MCP stdio bridge. It discovers the remote tools, resources, templates, and prompts; calls every advertised read-only tool, including article reading with a live slug and the hosting calculator with its defaults; reads resources; and retrieves prompts. The SDK validates tool results against the website's current schemas, and the profile resource is checked against the CLI's rendering contract. API and MCP failures are reported independently. Checks use live data, not snapshots of portfolio facts.
|
|
142
|
-
|
|
143
|
-
After a website deployment, run `mise run smoke` locally or dispatch the workflow:
|
|
144
|
-
|
|
145
|
-
```bash
|
|
146
|
-
gh workflow run compatibility.yml --repo fmind/cli
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
A failure indicates either an outage or a contract change; inspect the failing operation in the run log. Keep `PROFILE_SHAPE` limited to consumed fields and update synthetic fixtures alongside rendering changes. New MCP tools with required inputs may need a sample argument in the smoke check. No release is needed merely to add content or an unused API field.
|
|
150
|
-
|
|
151
|
-
Commands exit with 0 on success, 1 for website or article-resolution failures, and 2 for invalid CLI usage.
|
|
152
|
-
|
|
153
129
|
## License
|
|
154
130
|
|
|
155
131
|
[MIT](LICENSE) — © 2026 Médéric Hurier (Fmind).
|
|
@@ -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:
|
|
@@ -119,12 +158,32 @@ def _validate(value: object, shape: object, path: str) -> None:
|
|
|
119
158
|
raise FmindError(f"the profile endpoint returned an invalid portfolio document at {path}")
|
|
120
159
|
|
|
121
160
|
|
|
161
|
+
def _reject_constant(name: str) -> None:
|
|
162
|
+
"""Refuse NaN and Infinity: they are not JSON, so `--json` could not forward them."""
|
|
163
|
+
raise ValueError(name)
|
|
164
|
+
|
|
165
|
+
|
|
122
166
|
def _parse_profile(payload: bytes) -> dict[str, Any]:
|
|
123
167
|
"""Decode the portfolio document, rejecting anything that is not one."""
|
|
124
168
|
try:
|
|
125
|
-
document = json.loads(payload)
|
|
169
|
+
document = json.loads(payload, parse_constant=_reject_constant)
|
|
126
170
|
except (ValueError, RecursionError) as error:
|
|
127
171
|
raise FmindError("the profile endpoint did not return JSON") from error
|
|
172
|
+
# Python's JSON decoder accepts lone surrogates. Check keys and future fields
|
|
173
|
+
# too: JSON output forwards them even when the renderer does not consume them.
|
|
174
|
+
pending = [document]
|
|
175
|
+
while pending:
|
|
176
|
+
value = pending.pop()
|
|
177
|
+
if isinstance(value, str):
|
|
178
|
+
try:
|
|
179
|
+
value.encode("utf-8")
|
|
180
|
+
except UnicodeEncodeError as error:
|
|
181
|
+
raise FmindError("the profile endpoint returned invalid Unicode") from error
|
|
182
|
+
elif isinstance(value, dict):
|
|
183
|
+
pending.extend(value)
|
|
184
|
+
pending.extend(value.values())
|
|
185
|
+
elif isinstance(value, list):
|
|
186
|
+
pending.extend(value)
|
|
128
187
|
_validate(document, PROFILE_SHAPE, "profile")
|
|
129
188
|
links = document["thesis"].get("links")
|
|
130
189
|
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:
|
|
@@ -109,7 +116,10 @@ def _write(text: str) -> None:
|
|
|
109
116
|
def _emit(ctx: typer.Context, body: RenderableType, payload: Any, *, as_json: bool, page: bool = False) -> None:
|
|
110
117
|
"""Print either the rendered section or its JSON data."""
|
|
111
118
|
if as_json or ctx.find_root().params["as_json"]:
|
|
112
|
-
|
|
119
|
+
# json.dumps escapes C0 controls but not DEL or C1 (such as CSI); fields the
|
|
120
|
+
# renderer does not validate still reach the terminal, so escape those too.
|
|
121
|
+
text = json.dumps(payload, ensure_ascii=False, indent=2)
|
|
122
|
+
_write(CONTROL_PATTERN.sub(lambda match: f"\\u{ord(match.group()):04x}", text) + "\n")
|
|
113
123
|
return
|
|
114
124
|
out = _console(ctx)
|
|
115
125
|
if not out.is_terminal:
|
|
@@ -119,8 +129,11 @@ def _emit(ctx: typer.Context, body: RenderableType, payload: Any, *, as_json: bo
|
|
|
119
129
|
if command is None:
|
|
120
130
|
out.print(body, crop=False)
|
|
121
131
|
return
|
|
122
|
-
|
|
123
|
-
out.
|
|
132
|
+
try:
|
|
133
|
+
with out.pager(pager=_SystemPager(command), styles=True):
|
|
134
|
+
out.print(body, crop=False)
|
|
135
|
+
except FmindError as error:
|
|
136
|
+
raise _fail(str(error)) from error
|
|
124
137
|
|
|
125
138
|
|
|
126
139
|
@app.command()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|