fmind 2.0.3__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 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
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
@@ -146,7 +147,7 @@ mise run smoke # exercise every command against the live site
146
147
  mise run all # format, check, test, build — the gate CI runs
147
148
  ```
148
149
 
149
- 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.13 for compatibility, and tests the declared dependency floors with `--resolution lowest-direct` on Python 3.11.
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.
150
151
 
151
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.
152
153
 
@@ -122,7 +122,7 @@ mise run smoke # exercise every command against the live site
122
122
  mise run all # format, check, test, build — the gate CI runs
123
123
  ```
124
124
 
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.13 for compatibility, and tests the declared dependency floors with `--resolution lowest-direct` on Python 3.11.
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.
126
126
 
127
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.
128
128
 
@@ -1,9 +1,10 @@
1
1
  [project]
2
2
  name = "fmind"
3
- version = "2.0.3"
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.12.17,<0.13"]
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.3"
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.12.17,<0.13"]
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 os.environ.get("FMIND_PROFILE_URL", PROFILE_URL))
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
 
@@ -169,11 +175,14 @@ def _parse_profile(payload: bytes) -> dict[str, Any]:
169
175
  document = json.loads(payload, parse_constant=_reject_constant)
170
176
  except (ValueError, RecursionError) as error:
171
177
  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.
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.
174
181
  pending = [document]
175
182
  while pending:
176
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")
177
186
  if isinstance(value, str):
178
187
  try:
179
188
  value.encode("utf-8")
@@ -206,8 +215,7 @@ def _parse_markdown(payload: bytes) -> str:
206
215
 
207
216
  def load_profile(*, url: str | None = None) -> dict[str, Any]:
208
217
  """Return the portfolio document behind every section command."""
209
- endpoint = url if url is not None else os.environ.get("FMIND_PROFILE_URL", PROFILE_URL)
210
- return _parse_profile(_download(endpoint))
218
+ return _parse_profile(_download(url if url is not None else _profile_url()))
211
219
 
212
220
 
213
221
  def load_article(slug: str, *, profile_url: str | None = None) -> str:
@@ -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
- async for chunk in self.stream:
30
- size += len(chunk)
31
- if size > MAX_BYTES:
32
- raise FmindError("the remote MCP response exceeded the size limit")
33
- yield chunk
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
- response = await super().handle_async_request(request)
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 _rows(
91
- [
92
- ("Name", Text(f"{meta['name']} ({meta['alternate_name']})", style="bold")),
93
- ("Role", Text(meta["job_title"])),
94
- ("Headline", Text(meta["headline_primary"])),
95
- ("Experience", mission),
96
- ("Status", status),
97
- ("Contact", _link(meta["email"])),
98
- ("Website", _link(meta["site_url"])),
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
File without changes