fmind 2.0.2__tar.gz → 2.0.4__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.4/LICENSE +21 -0
- {fmind-2.0.2 → fmind-2.0.4}/PKG-INFO +3 -26
- {fmind-2.0.2 → fmind-2.0.4}/README.md +1 -25
- {fmind-2.0.2 → fmind-2.0.4}/pyproject.toml +3 -2
- {fmind-2.0.2 → fmind-2.0.4}/pyproject.toml.orig +3 -2
- {fmind-2.0.2 → fmind-2.0.4}/src/fmind/api.py +19 -6
- {fmind-2.0.2 → fmind-2.0.4}/src/fmind/cli.py +4 -1
- {fmind-2.0.2 → fmind-2.0.4}/src/fmind/mcp.py +20 -8
- {fmind-2.0.2 → fmind-2.0.4}/src/fmind/render.py +16 -10
- {fmind-2.0.2 → fmind-2.0.4}/src/fmind/__init__.py +0 -0
- {fmind-2.0.2 → fmind-2.0.4}/src/fmind/__main__.py +0 -0
- {fmind-2.0.2 → fmind-2.0.4}/src/fmind/articles.py +0 -0
fmind-2.0.4/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Médéric Hurier (Fmind)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fmind
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.4
|
|
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
|
|
7
7
|
Author-email: Médéric Hurier <contact@fmind.dev>
|
|
8
8
|
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
9
10
|
Classifier: Environment :: Console
|
|
10
11
|
Classifier: Intended Audience :: Developers
|
|
11
12
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
@@ -30,14 +31,6 @@ Read [Médéric Hurier's (Fmind)](https://www.fmind.dev/) portfolio from the ter
|
|
|
30
31
|
uvx fmind whoami
|
|
31
32
|
```
|
|
32
33
|
|
|
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. 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
|
-
|
|
41
34
|
## Commands
|
|
42
35
|
|
|
43
36
|
| Command | Shows |
|
|
@@ -154,26 +147,10 @@ mise run smoke # exercise every command against the live site
|
|
|
154
147
|
mise run all # format, check, test, build — the gate CI runs
|
|
155
148
|
```
|
|
156
149
|
|
|
157
|
-
The pre-commit hook runs checks without changing or staging files; run `mise run format` before committing. The pre-push hook runs the offline tests. CI runs the full gate on the pinned Python, tests Python 3.11–3.
|
|
150
|
+
The pre-commit hook runs checks without changing or staging files; run `mise run format` before committing. The pre-push hook runs the offline tests. CI runs the full gate on the pinned Python, tests Python 3.11–3.14 for compatibility, and tests the declared dependency floors with `--resolution lowest-direct` on Python 3.11.
|
|
158
151
|
|
|
159
152
|
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
153
|
|
|
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
154
|
## License
|
|
178
155
|
|
|
179
156
|
[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. 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
|
-
|
|
17
9
|
## Commands
|
|
18
10
|
|
|
19
11
|
| Command | Shows |
|
|
@@ -130,26 +122,10 @@ mise run smoke # exercise every command against the live site
|
|
|
130
122
|
mise run all # format, check, test, build — the gate CI runs
|
|
131
123
|
```
|
|
132
124
|
|
|
133
|
-
The pre-commit hook runs checks without changing or staging files; run `mise run format` before committing. The pre-push hook runs the offline tests. CI runs the full gate on the pinned Python, tests Python 3.11–3.
|
|
125
|
+
The pre-commit hook runs checks without changing or staging files; run `mise run format` before committing. The pre-push hook runs the offline tests. CI runs the full gate on the pinned Python, tests Python 3.11–3.14 for compatibility, and tests the declared dependency floors with `--resolution lowest-direct` on Python 3.11.
|
|
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).
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fmind"
|
|
3
|
-
version = "2.0.
|
|
3
|
+
version = "2.0.4"
|
|
4
4
|
description = "Read Médéric Hurier's (Fmind) portfolio from the terminal."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
7
8
|
requires-python = ">=3.11"
|
|
8
9
|
keywords = [
|
|
9
10
|
"portfolio",
|
|
@@ -51,7 +52,7 @@ dev = [
|
|
|
51
52
|
]
|
|
52
53
|
|
|
53
54
|
[build-system]
|
|
54
|
-
requires = ["uv_build>=0.
|
|
55
|
+
requires = ["uv_build>=0.13.0,<0.14"]
|
|
55
56
|
build-backend = "uv_build"
|
|
56
57
|
|
|
57
58
|
[tool.uv.build-backend]
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# https://packaging.python.org/en/latest/specifications/pyproject-toml/
|
|
2
2
|
[project]
|
|
3
3
|
name = "fmind"
|
|
4
|
-
version = "2.0.
|
|
4
|
+
version = "2.0.4"
|
|
5
5
|
description = "Read Médéric Hurier's (Fmind) portfolio from the terminal."
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
8
9
|
authors = [{ name = "Médéric Hurier", email = "contact@fmind.dev" }]
|
|
9
10
|
requires-python = ">=3.11"
|
|
10
11
|
keywords = ["portfolio", "cli", "fmind", "resume", "articles"]
|
|
@@ -40,7 +41,7 @@ dev = [
|
|
|
40
41
|
]
|
|
41
42
|
|
|
42
43
|
[build-system]
|
|
43
|
-
requires = ["uv_build>=0.
|
|
44
|
+
requires = ["uv_build>=0.13.0,<0.14"]
|
|
44
45
|
build-backend = "uv_build"
|
|
45
46
|
|
|
46
47
|
[tool.uv.build-backend]
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import http.client
|
|
6
6
|
import json
|
|
7
|
+
import math
|
|
7
8
|
import os
|
|
8
9
|
import queue
|
|
9
10
|
import re
|
|
@@ -76,9 +77,14 @@ def article_url(slug: str, *, profile_url: str | None = None) -> str:
|
|
|
76
77
|
return origin_url(f"/articles/{slug}.md", profile_url=profile_url)
|
|
77
78
|
|
|
78
79
|
|
|
80
|
+
def _profile_url() -> str:
|
|
81
|
+
"""Return the configured profile endpoint, read at call time."""
|
|
82
|
+
return os.environ.get("FMIND_PROFILE_URL", PROFILE_URL)
|
|
83
|
+
|
|
84
|
+
|
|
79
85
|
def origin_url(path: str, *, profile_url: str | None = None) -> str:
|
|
80
86
|
"""Resolve a website endpoint on the configured profile origin."""
|
|
81
|
-
parts = _http_url(profile_url if profile_url is not None else
|
|
87
|
+
parts = _http_url(profile_url if profile_url is not None else _profile_url())
|
|
82
88
|
return urllib.parse.urlunsplit((parts.scheme, parts.netloc, path, "", ""))
|
|
83
89
|
|
|
84
90
|
|
|
@@ -158,17 +164,25 @@ def _validate(value: object, shape: object, path: str) -> None:
|
|
|
158
164
|
raise FmindError(f"the profile endpoint returned an invalid portfolio document at {path}")
|
|
159
165
|
|
|
160
166
|
|
|
167
|
+
def _reject_constant(name: str) -> None:
|
|
168
|
+
"""Refuse NaN and Infinity: they are not JSON, so `--json` could not forward them."""
|
|
169
|
+
raise ValueError(name)
|
|
170
|
+
|
|
171
|
+
|
|
161
172
|
def _parse_profile(payload: bytes) -> dict[str, Any]:
|
|
162
173
|
"""Decode the portfolio document, rejecting anything that is not one."""
|
|
163
174
|
try:
|
|
164
|
-
document = json.loads(payload)
|
|
175
|
+
document = json.loads(payload, parse_constant=_reject_constant)
|
|
165
176
|
except (ValueError, RecursionError) as error:
|
|
166
177
|
raise FmindError("the profile endpoint did not return JSON") from error
|
|
167
|
-
#
|
|
168
|
-
#
|
|
178
|
+
# Check keys and future fields too: JSON output forwards them even when the
|
|
179
|
+
# renderer does not consume them. The decoder accepts surrogates and can turn
|
|
180
|
+
# finite JSON literals such as 1e999 into infinity.
|
|
169
181
|
pending = [document]
|
|
170
182
|
while pending:
|
|
171
183
|
value = pending.pop()
|
|
184
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
185
|
+
raise FmindError("the profile endpoint returned a non-finite number")
|
|
172
186
|
if isinstance(value, str):
|
|
173
187
|
try:
|
|
174
188
|
value.encode("utf-8")
|
|
@@ -201,8 +215,7 @@ def _parse_markdown(payload: bytes) -> str:
|
|
|
201
215
|
|
|
202
216
|
def load_profile(*, url: str | None = None) -> dict[str, Any]:
|
|
203
217
|
"""Return the portfolio document behind every section command."""
|
|
204
|
-
|
|
205
|
-
return _parse_profile(_download(endpoint))
|
|
218
|
+
return _parse_profile(_download(url if url is not None else _profile_url()))
|
|
206
219
|
|
|
207
220
|
|
|
208
221
|
def load_article(slug: str, *, profile_url: str | None = None) -> str:
|
|
@@ -116,7 +116,10 @@ def _write(text: str) -> None:
|
|
|
116
116
|
def _emit(ctx: typer.Context, body: RenderableType, payload: Any, *, as_json: bool, page: bool = False) -> None:
|
|
117
117
|
"""Print either the rendered section or its JSON data."""
|
|
118
118
|
if as_json or ctx.find_root().params["as_json"]:
|
|
119
|
-
|
|
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")
|
|
120
123
|
return
|
|
121
124
|
out = _console(ctx)
|
|
122
125
|
if not out.is_terminal:
|
|
@@ -21,16 +21,21 @@ from fmind.api import MAX_BYTES, TIMEOUT_SECONDS, USER_AGENT, FmindError, origin
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
class _BoundedStream(httpx2.AsyncByteStream):
|
|
24
|
-
def __init__(self, stream: httpx2.AsyncByteStream) -> None:
|
|
24
|
+
def __init__(self, stream: httpx2.AsyncByteStream, deadline: float) -> None:
|
|
25
25
|
self.stream = stream
|
|
26
|
+
self.deadline = deadline
|
|
26
27
|
|
|
27
28
|
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
28
29
|
size = 0
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
try:
|
|
31
|
+
async with asyncio.timeout_at(self.deadline):
|
|
32
|
+
async for chunk in self.stream:
|
|
33
|
+
size += len(chunk)
|
|
34
|
+
if size > MAX_BYTES:
|
|
35
|
+
raise FmindError("the remote MCP response exceeded the size limit")
|
|
36
|
+
yield chunk
|
|
37
|
+
except TimeoutError as error:
|
|
38
|
+
raise httpx2.ReadTimeout("the remote MCP response exceeded its deadline") from error
|
|
34
39
|
|
|
35
40
|
async def aclose(self) -> None:
|
|
36
41
|
await self.stream.aclose()
|
|
@@ -38,13 +43,20 @@ class _BoundedStream(httpx2.AsyncByteStream):
|
|
|
38
43
|
|
|
39
44
|
class _BoundedTransport(httpx2.AsyncHTTPTransport):
|
|
40
45
|
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
|
|
41
|
-
|
|
46
|
+
# HTTPX read timeouts restart for every chunk. A legacy MCP server may
|
|
47
|
+
# ignore cancellation, so bound the entire HTTP exchange independently.
|
|
48
|
+
deadline = asyncio.get_running_loop().time() + TIMEOUT_SECONDS
|
|
49
|
+
try:
|
|
50
|
+
async with asyncio.timeout_at(deadline):
|
|
51
|
+
response = await super().handle_async_request(request)
|
|
52
|
+
except TimeoutError as error:
|
|
53
|
+
raise httpx2.ReadTimeout("the remote MCP response exceeded its deadline", request=request) from error
|
|
42
54
|
if response.headers.get("content-encoding", "identity") != "identity":
|
|
43
55
|
await response.aclose()
|
|
44
56
|
raise FmindError("the remote MCP server ignored the uncompressed response requirement")
|
|
45
57
|
if not isinstance(response.stream, httpx2.AsyncByteStream):
|
|
46
58
|
raise FmindError("the remote MCP transport returned an invalid stream")
|
|
47
|
-
response.stream = _BoundedStream(response.stream)
|
|
59
|
+
response.stream = _BoundedStream(response.stream, deadline)
|
|
48
60
|
return response
|
|
49
61
|
|
|
50
62
|
|
|
@@ -87,16 +87,22 @@ def whoami(doc: dict[str, Any]) -> RenderableType:
|
|
|
87
87
|
status.append("\n")
|
|
88
88
|
status.append(service["title"], style="bold")
|
|
89
89
|
status.append(f" — {service['badge']}")
|
|
90
|
-
return
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
90
|
+
return Group(
|
|
91
|
+
_rows(
|
|
92
|
+
[
|
|
93
|
+
("Name", Text(f"{meta['name']} ({meta['alternate_name']})", style="bold")),
|
|
94
|
+
("Role", Text(meta["job_title"])),
|
|
95
|
+
("Headline", Text(meta["headline_primary"])),
|
|
96
|
+
("Experience", mission),
|
|
97
|
+
("Status", status),
|
|
98
|
+
]
|
|
99
|
+
),
|
|
100
|
+
# Table cells crop even no-wrap links. Keep these outside the grid,
|
|
101
|
+
# with the same 11-character label and two-space gap.
|
|
102
|
+
*(
|
|
103
|
+
Text.assemble((f"{label:<13}", ACCENT), _link(value), no_wrap=True, overflow="ignore")
|
|
104
|
+
for label, value in (("Contact", meta["email"]), ("Website", meta["site_url"]))
|
|
105
|
+
),
|
|
100
106
|
)
|
|
101
107
|
|
|
102
108
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|