fmind 0.1.0__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-0.1.0/PKG-INFO +113 -0
- fmind-0.1.0/README.md +93 -0
- fmind-0.1.0/pyproject.toml +85 -0
- fmind-0.1.0/pyproject.toml.orig +58 -0
- fmind-0.1.0/src/fmind/__init__.py +9 -0
- fmind-0.1.0/src/fmind/__main__.py +6 -0
- fmind-0.1.0/src/fmind/api.py +157 -0
- fmind-0.1.0/src/fmind/articles.py +48 -0
- fmind-0.1.0/src/fmind/cli.py +192 -0
- fmind-0.1.0/src/fmind/render.py +272 -0
fmind-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fmind
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read Médéric Hurier's (Fmind) portfolio from the terminal.
|
|
5
|
+
Keywords: portfolio,cli,fmind,resume,articles
|
|
6
|
+
Author: Médéric Hurier
|
|
7
|
+
Author-email: Médéric Hurier <contact@fmind.dev>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Topic :: Utilities
|
|
13
|
+
Requires-Dist: typer>=0.15
|
|
14
|
+
Requires-Dist: rich>=13.9
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Project-URL: Homepage, https://www.fmind.dev/
|
|
17
|
+
Project-URL: Source, https://github.com/fmind/cli
|
|
18
|
+
Project-URL: Issues, https://github.com/fmind/cli/issues
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# fmind
|
|
22
|
+
|
|
23
|
+
Read [Médéric Hurier's (Fmind)](https://www.fmind.dev/) portfolio from the terminal.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uvx fmind whoami
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Why it stays small
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
|
|
33
|
+
This package therefore carries no copy of the portfolio and needs **no release when the website changes**: new articles, a renewed certification or a new engagement appear on the next run.
|
|
34
|
+
|
|
35
|
+
Responses are cached under `${XDG_CACHE_HOME:-~/.cache}/fmind/` for one hour, matching the endpoints' own `Cache-Control`. Once a cache exists the CLI also works offline, falling back to the stale copy when the network is unavailable.
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
| Command | Shows |
|
|
40
|
+
| --------------------------- | ------------------------------------------------- |
|
|
41
|
+
| `fmind whoami` | Name, current mission, contact, availability |
|
|
42
|
+
| `fmind about` | The biography |
|
|
43
|
+
| `fmind skills` | Core expertise, laid out like a usage screen |
|
|
44
|
+
| `fmind work` | Engagements, current one first |
|
|
45
|
+
| `fmind community` | Ambassador and advisory roles |
|
|
46
|
+
| `fmind cert [--verify]` | Certifications, the PhD, specializations |
|
|
47
|
+
| `fmind papers` | The doctorate and the peer-reviewed publications |
|
|
48
|
+
| `fmind project [--top 6]` | Open-source repositories and video series |
|
|
49
|
+
| `fmind sites` | Interactive tools published alongside the writing |
|
|
50
|
+
| `fmind article [--limit 6]` | The most recent writing |
|
|
51
|
+
| `fmind search <query>` | Articles matching every term, newest first |
|
|
52
|
+
| `fmind read <slug> [--raw]` | One article, rendered in the terminal |
|
|
53
|
+
| `fmind hire` | What can be booked right now |
|
|
54
|
+
|
|
55
|
+
Global options: `--refresh` to bypass the cache, `--json` to emit the raw section, `--no-color` for pipes, `--version`.
|
|
56
|
+
|
|
57
|
+
### Reading
|
|
58
|
+
|
|
59
|
+
`fmind search` matches titles, summaries, tags and slugs; terms are ANDed, so each extra word narrows the result. `--tag` restricts to one of the site's own tags.
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
fmind search "agent security"
|
|
63
|
+
fmind search "" --tag MLOps --limit 3
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`fmind read` takes a slug, or enough of one to be unambiguous, and prints the article. Use `--raw` for the Markdown source.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
fmind read agentgateway # resolves to agentgateway-vs-litellm
|
|
70
|
+
fmind read mlops-adventure-continue --raw | glow -
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Piping
|
|
74
|
+
|
|
75
|
+
`--json` turns every command into a data source, so the portfolio composes with the rest of the shell.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
fmind --json article --limit 20 | jq -r '.[] | "\(.date[:10]) \(.title)"'
|
|
79
|
+
fmind --json search agent | jq -r '.[].url'
|
|
80
|
+
fmind --json cert --verify | jq -r '.[].title'
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Install
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
uvx fmind whoami # zero install
|
|
87
|
+
uv tool install fmind # persistent
|
|
88
|
+
pipx install fmind # alternative
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Point it at another origin with `FMIND_PROFILE_URL` — useful against a local `mise run watch` server of the website:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
FMIND_PROFILE_URL=http://127.0.0.1:8080/api/profile fmind whoami
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Article reads follow the same origin, so one variable moves the whole CLI.
|
|
98
|
+
|
|
99
|
+
## Development
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
mise install # install the pinned toolchain
|
|
103
|
+
mise run install # sync the locked environment and install hooks
|
|
104
|
+
mise run format # ruff imports and format, dprint
|
|
105
|
+
mise run check # ruff, ty, actionlint, zizmor, gitleaks
|
|
106
|
+
mise run test # offline pytest with branch coverage
|
|
107
|
+
mise run smoke # exercise every command against the live site
|
|
108
|
+
mise run all # format, check, test, build — the gate CI runs
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
[MIT](LICENSE) — © 2026 Médéric Hurier (Fmind).
|
fmind-0.1.0/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# fmind
|
|
2
|
+
|
|
3
|
+
Read [Médéric Hurier's (Fmind)](https://www.fmind.dev/) portfolio from the terminal.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
uvx fmind whoami
|
|
7
|
+
```
|
|
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 therefore carries no copy of the portfolio and needs **no release when the website changes**: new articles, a renewed certification or a new engagement appear on the next run.
|
|
14
|
+
|
|
15
|
+
Responses are cached under `${XDG_CACHE_HOME:-~/.cache}/fmind/` for one hour, matching the endpoints' own `Cache-Control`. Once a cache exists the CLI also works offline, falling back to the stale copy when the network is unavailable.
|
|
16
|
+
|
|
17
|
+
## Commands
|
|
18
|
+
|
|
19
|
+
| Command | Shows |
|
|
20
|
+
| --------------------------- | ------------------------------------------------- |
|
|
21
|
+
| `fmind whoami` | Name, current mission, contact, availability |
|
|
22
|
+
| `fmind about` | The biography |
|
|
23
|
+
| `fmind skills` | Core expertise, laid out like a usage screen |
|
|
24
|
+
| `fmind work` | Engagements, current one first |
|
|
25
|
+
| `fmind community` | Ambassador and advisory roles |
|
|
26
|
+
| `fmind cert [--verify]` | Certifications, the PhD, specializations |
|
|
27
|
+
| `fmind papers` | The doctorate and the peer-reviewed publications |
|
|
28
|
+
| `fmind project [--top 6]` | Open-source repositories and video series |
|
|
29
|
+
| `fmind sites` | Interactive tools published alongside the writing |
|
|
30
|
+
| `fmind article [--limit 6]` | The most recent writing |
|
|
31
|
+
| `fmind search <query>` | Articles matching every term, newest first |
|
|
32
|
+
| `fmind read <slug> [--raw]` | One article, rendered in the terminal |
|
|
33
|
+
| `fmind hire` | What can be booked right now |
|
|
34
|
+
|
|
35
|
+
Global options: `--refresh` to bypass the cache, `--json` to emit the raw section, `--no-color` for pipes, `--version`.
|
|
36
|
+
|
|
37
|
+
### Reading
|
|
38
|
+
|
|
39
|
+
`fmind search` matches titles, summaries, tags and slugs; terms are ANDed, so each extra word narrows the result. `--tag` restricts to one of the site's own tags.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
fmind search "agent security"
|
|
43
|
+
fmind search "" --tag MLOps --limit 3
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`fmind read` takes a slug, or enough of one to be unambiguous, and prints the article. Use `--raw` for the Markdown source.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
fmind read agentgateway # resolves to agentgateway-vs-litellm
|
|
50
|
+
fmind read mlops-adventure-continue --raw | glow -
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Piping
|
|
54
|
+
|
|
55
|
+
`--json` turns every command into a data source, so the portfolio composes with the rest of the shell.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
fmind --json article --limit 20 | jq -r '.[] | "\(.date[:10]) \(.title)"'
|
|
59
|
+
fmind --json search agent | jq -r '.[].url'
|
|
60
|
+
fmind --json cert --verify | jq -r '.[].title'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uvx fmind whoami # zero install
|
|
67
|
+
uv tool install fmind # persistent
|
|
68
|
+
pipx install fmind # alternative
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Point it at another origin with `FMIND_PROFILE_URL` — useful against a local `mise run watch` server of the website:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
FMIND_PROFILE_URL=http://127.0.0.1:8080/api/profile fmind whoami
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Article reads follow the same origin, so one variable moves the whole CLI.
|
|
78
|
+
|
|
79
|
+
## Development
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
mise install # install the pinned toolchain
|
|
83
|
+
mise run install # sync the locked environment and install hooks
|
|
84
|
+
mise run format # ruff imports and format, dprint
|
|
85
|
+
mise run check # ruff, ty, actionlint, zizmor, gitleaks
|
|
86
|
+
mise run test # offline pytest with branch coverage
|
|
87
|
+
mise run smoke # exercise every command against the live site
|
|
88
|
+
mise run all # format, check, test, build — the gate CI runs
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
[MIT](LICENSE) — © 2026 Médéric Hurier (Fmind).
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fmind"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Read Médéric Hurier's (Fmind) portfolio from the terminal."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
keywords = [
|
|
9
|
+
"portfolio",
|
|
10
|
+
"cli",
|
|
11
|
+
"fmind",
|
|
12
|
+
"resume",
|
|
13
|
+
"articles",
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Topic :: Utilities",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"typer>=0.15",
|
|
23
|
+
"rich>=13.9",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[[project.authors]]
|
|
27
|
+
name = "Médéric Hurier"
|
|
28
|
+
email = "contact@fmind.dev"
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://www.fmind.dev/"
|
|
32
|
+
Source = "https://github.com/fmind/cli"
|
|
33
|
+
Issues = "https://github.com/fmind/cli/issues"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
fmind = "fmind.cli:app"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = [
|
|
40
|
+
"pytest>=8.3",
|
|
41
|
+
"pytest-cov>=6.0",
|
|
42
|
+
"ruff>=0.9",
|
|
43
|
+
"ty>=0.0.1a1",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[build-system]
|
|
47
|
+
requires = ["uv_build>=0.12.10,<0.13"]
|
|
48
|
+
build-backend = "uv_build"
|
|
49
|
+
|
|
50
|
+
[tool.uv.build-backend]
|
|
51
|
+
module-name = "fmind"
|
|
52
|
+
module-root = "src"
|
|
53
|
+
|
|
54
|
+
[tool.ruff]
|
|
55
|
+
line-length = 120
|
|
56
|
+
target-version = "py311"
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint]
|
|
59
|
+
select = [
|
|
60
|
+
"E",
|
|
61
|
+
"F",
|
|
62
|
+
"I",
|
|
63
|
+
"N",
|
|
64
|
+
"UP",
|
|
65
|
+
"B",
|
|
66
|
+
"S",
|
|
67
|
+
"SIM",
|
|
68
|
+
"RUF",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint.per-file-ignores]
|
|
72
|
+
"tests/**" = ["S101"]
|
|
73
|
+
|
|
74
|
+
[tool.ruff.format]
|
|
75
|
+
docstring-code-format = true
|
|
76
|
+
|
|
77
|
+
[tool.pytest.ini_options]
|
|
78
|
+
addopts = "-ra --strict-markers --cov=fmind --cov-branch --cov-fail-under=85"
|
|
79
|
+
testpaths = ["tests"]
|
|
80
|
+
|
|
81
|
+
[tool.coverage.report]
|
|
82
|
+
exclude_also = [
|
|
83
|
+
"if __name__ == .__main__.:",
|
|
84
|
+
"raise NotImplementedError",
|
|
85
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# https://packaging.python.org/en/latest/specifications/pyproject-toml/
|
|
2
|
+
[project]
|
|
3
|
+
name = "fmind"
|
|
4
|
+
version = "0.1.0"
|
|
5
|
+
description = "Read Médéric Hurier's (Fmind) portfolio from the terminal."
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "Médéric Hurier", email = "contact@fmind.dev" }]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
keywords = ["portfolio", "cli", "fmind", "resume", "articles"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
15
|
+
"Topic :: Utilities",
|
|
16
|
+
]
|
|
17
|
+
dependencies = ["typer>=0.15", "rich>=13.9"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://www.fmind.dev/"
|
|
21
|
+
Source = "https://github.com/fmind/cli"
|
|
22
|
+
Issues = "https://github.com/fmind/cli/issues"
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
fmind = "fmind.cli:app"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=8.3",
|
|
30
|
+
"pytest-cov>=6.0",
|
|
31
|
+
"ruff>=0.9",
|
|
32
|
+
"ty>=0.0.1a1",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[build-system]
|
|
36
|
+
requires = ["uv_build>=0.12.10,<0.13"]
|
|
37
|
+
build-backend = "uv_build"
|
|
38
|
+
|
|
39
|
+
[tool.uv.build-backend]
|
|
40
|
+
module-name = "fmind"
|
|
41
|
+
module-root = "src"
|
|
42
|
+
|
|
43
|
+
[tool.ruff]
|
|
44
|
+
line-length = 120
|
|
45
|
+
target-version = "py311"
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = ["E", "F", "I", "N", "UP", "B", "S", "SIM", "RUF"]
|
|
48
|
+
[tool.ruff.lint.per-file-ignores]
|
|
49
|
+
"tests/**" = ["S101"] # assertions are the point of a test
|
|
50
|
+
[tool.ruff.format]
|
|
51
|
+
docstring-code-format = true
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
addopts = "-ra --strict-markers --cov=fmind --cov-branch --cov-fail-under=85"
|
|
55
|
+
testpaths = ["tests"]
|
|
56
|
+
|
|
57
|
+
[tool.coverage.report]
|
|
58
|
+
exclude_also = ["if __name__ == .__main__.:", "raise NotImplementedError"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Read Médéric Hurier's (Fmind) portfolio from the terminal.
|
|
2
|
+
|
|
3
|
+
Every command renders https://www.fmind.dev/api/profile, so this package carries
|
|
4
|
+
no copy of its own and needs no release when the website changes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__all__ = ["__version__"]
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Fetch and cache the documents published by www.fmind.dev."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, TypeVar
|
|
14
|
+
|
|
15
|
+
from fmind import __version__
|
|
16
|
+
|
|
17
|
+
PROFILE_URL = os.environ.get("FMIND_PROFILE_URL", "https://www.fmind.dev/api/profile")
|
|
18
|
+
# The endpoints advertise `public, max-age=3600`; the on-disk copies honour the same window.
|
|
19
|
+
CACHE_TTL_SECONDS = 3600
|
|
20
|
+
TIMEOUT_SECONDS = 15.0
|
|
21
|
+
# Generous next to a ~75 KB profile and ~20 KB articles, but bounded: a hostile or
|
|
22
|
+
# misrouted origin must not be able to fill the cache directory.
|
|
23
|
+
MAX_BYTES = 8 * 1024 * 1024
|
|
24
|
+
USER_AGENT = f"fmind/{__version__} (+https://github.com/fmind/cli)"
|
|
25
|
+
# Slugs reach the URL and the cache path, so only the shape the site actually mints is accepted.
|
|
26
|
+
SLUG_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*\Z")
|
|
27
|
+
|
|
28
|
+
T = TypeVar("T")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FmindError(RuntimeError):
|
|
32
|
+
"""A document could not be obtained, or was not the document that was asked for."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cache_path(*parts: str) -> Path:
|
|
36
|
+
"""Return an on-disk cache location, honouring XDG_CACHE_HOME."""
|
|
37
|
+
root = os.environ.get("XDG_CACHE_HOME")
|
|
38
|
+
base = Path(root) if root else Path.home() / ".cache"
|
|
39
|
+
return base.joinpath("fmind", *parts)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def article_url(slug: str, *, profile_url: str = PROFILE_URL) -> str:
|
|
43
|
+
"""Return the Markdown source URL of an article, on the same origin as the profile."""
|
|
44
|
+
if not SLUG_PATTERN.match(slug):
|
|
45
|
+
msg = f"not an article slug: {slug!r}"
|
|
46
|
+
raise FmindError(msg)
|
|
47
|
+
origin = profile_url.removesuffix("/api/profile").rstrip("/")
|
|
48
|
+
return f"{origin}/articles/{slug}.md"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _read_cache(path: Path, ttl: int) -> bytes | None:
|
|
52
|
+
"""Return the cached bytes while they are younger than `ttl`; `ttl < 0` accepts any age."""
|
|
53
|
+
try:
|
|
54
|
+
age = time.time() - path.stat().st_mtime
|
|
55
|
+
except OSError:
|
|
56
|
+
return None
|
|
57
|
+
if ttl >= 0 and age > ttl:
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
return path.read_bytes()
|
|
61
|
+
except OSError:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _write_cache(path: Path, payload: bytes) -> None:
|
|
66
|
+
"""Persist a document, ignoring an unwritable cache directory."""
|
|
67
|
+
try:
|
|
68
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
70
|
+
tmp.write_bytes(payload)
|
|
71
|
+
tmp.replace(path)
|
|
72
|
+
except OSError:
|
|
73
|
+
pass # a read-only cache must not break a working command
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _download(url: str, accept: str = "application/json") -> bytes:
|
|
77
|
+
"""Fetch a document over HTTP(S) with an explicit timeout and size bound."""
|
|
78
|
+
if not url.startswith(("https://", "http://")):
|
|
79
|
+
msg = f"URL must be http(s): {url}"
|
|
80
|
+
raise FmindError(msg)
|
|
81
|
+
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": accept}) # noqa: S310
|
|
82
|
+
try:
|
|
83
|
+
with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response: # noqa: S310
|
|
84
|
+
payload = response.read(MAX_BYTES + 1)
|
|
85
|
+
except urllib.error.HTTPError as error:
|
|
86
|
+
msg = f"{url} returned HTTP {error.code}"
|
|
87
|
+
raise FmindError(msg) from error
|
|
88
|
+
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
|
89
|
+
msg = f"could not reach {url}: {error}"
|
|
90
|
+
raise FmindError(msg) from error
|
|
91
|
+
if len(payload) > MAX_BYTES:
|
|
92
|
+
msg = f"{url} returned more than {MAX_BYTES} bytes"
|
|
93
|
+
raise FmindError(msg)
|
|
94
|
+
return payload
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _load(path: Path, url: str, accept: str, parse: Callable[[bytes], T], *, refresh: bool) -> T:
|
|
98
|
+
"""Return a parsed document from the cache or the network.
|
|
99
|
+
|
|
100
|
+
A fresh cache wins unless `refresh`. When the network fails — or answers with
|
|
101
|
+
something that is not the expected document — a stale cache is still served, so
|
|
102
|
+
the CLI keeps working offline once it has run at least once. Unparseable cached
|
|
103
|
+
bytes are discarded rather than surfaced.
|
|
104
|
+
"""
|
|
105
|
+
if not refresh:
|
|
106
|
+
cached = _read_cache(path, CACHE_TTL_SECONDS)
|
|
107
|
+
if cached is not None:
|
|
108
|
+
try:
|
|
109
|
+
return parse(cached)
|
|
110
|
+
except FmindError:
|
|
111
|
+
pass # a corrupt cache must not break a working command
|
|
112
|
+
try:
|
|
113
|
+
payload = _download(url, accept)
|
|
114
|
+
value = parse(payload)
|
|
115
|
+
except FmindError:
|
|
116
|
+
stale = _read_cache(path, -1)
|
|
117
|
+
if stale is not None:
|
|
118
|
+
try:
|
|
119
|
+
return parse(stale)
|
|
120
|
+
except FmindError:
|
|
121
|
+
pass
|
|
122
|
+
raise
|
|
123
|
+
_write_cache(path, payload)
|
|
124
|
+
return value
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_profile(payload: bytes) -> dict[str, Any]:
|
|
128
|
+
"""Decode the portfolio document, rejecting anything that is not one."""
|
|
129
|
+
try:
|
|
130
|
+
document = json.loads(payload)
|
|
131
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as error:
|
|
132
|
+
raise FmindError("the profile endpoint did not return JSON") from error
|
|
133
|
+
if not isinstance(document, dict) or "metadata" not in document:
|
|
134
|
+
raise FmindError("the profile endpoint did not return a portfolio document")
|
|
135
|
+
return document
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_markdown(payload: bytes) -> str:
|
|
139
|
+
"""Decode an article, rejecting an HTML error page served with a 200."""
|
|
140
|
+
try:
|
|
141
|
+
text = payload.decode("utf-8")
|
|
142
|
+
except UnicodeDecodeError as error:
|
|
143
|
+
raise FmindError("the article was not UTF-8 text") from error
|
|
144
|
+
if not text.strip() or text.lstrip().startswith("<"):
|
|
145
|
+
raise FmindError("the article endpoint did not return Markdown")
|
|
146
|
+
return text
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def load_profile(*, refresh: bool = False, url: str = PROFILE_URL) -> dict[str, Any]:
|
|
150
|
+
"""Return the portfolio document behind every section command."""
|
|
151
|
+
return _load(cache_path("profile.json"), url, "application/json", _parse_profile, refresh=refresh)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def load_article(slug: str, *, refresh: bool = False, profile_url: str = PROFILE_URL) -> str:
|
|
155
|
+
"""Return the Markdown source of one published article."""
|
|
156
|
+
url = article_url(slug, profile_url=profile_url)
|
|
157
|
+
return _load(cache_path("articles", f"{slug}.md"), url, "text/markdown", _parse_markdown, refresh=refresh)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Select articles from the profile document: newest, matching, or named."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
Article = dict[str, Any]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def latest(articles: list[Article], *, limit: int) -> list[Article]:
|
|
11
|
+
"""Return the most recently published articles, newest first."""
|
|
12
|
+
return sorted(articles, key=lambda a: a["date"], reverse=True)[: max(limit, 0)]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _haystack(article: Article) -> str:
|
|
16
|
+
"""Return the searchable text of one article, lower-cased."""
|
|
17
|
+
fields = [article["title"], article["description"], article["slug"], *article["tags"]]
|
|
18
|
+
return " ".join(fields).lower()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def search(articles: list[Article], query: str, *, tag: str | None = None, limit: int = 10) -> list[Article]:
|
|
22
|
+
"""Return newest-first articles whose text contains every term of `query`.
|
|
23
|
+
|
|
24
|
+
Terms are ANDed so that adding a word narrows the result, and `tag` further
|
|
25
|
+
restricts to one of the site's own tags. Both comparisons are case-insensitive.
|
|
26
|
+
"""
|
|
27
|
+
terms = query.lower().split()
|
|
28
|
+
wanted = tag.lower() if tag else None
|
|
29
|
+
found = [
|
|
30
|
+
article
|
|
31
|
+
for article in articles
|
|
32
|
+
if all(term in _haystack(article) for term in terms)
|
|
33
|
+
and (wanted is None or wanted in {t.lower() for t in article["tags"]})
|
|
34
|
+
]
|
|
35
|
+
return latest(found, limit=limit)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def candidates(articles: list[Article], wanted: str) -> list[str]:
|
|
39
|
+
"""Return the slugs `wanted` could mean: itself when exact, else every slug containing it."""
|
|
40
|
+
slugs = [article["slug"] for article in articles]
|
|
41
|
+
if wanted in slugs:
|
|
42
|
+
return [wanted]
|
|
43
|
+
return [slug for slug in slugs if wanted in slug]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def by_slug(articles: list[Article], slug: str) -> Article | None:
|
|
47
|
+
"""Return the article index entry for `slug`, if the profile lists it."""
|
|
48
|
+
return next((article for article in articles if article["slug"] == slug), None)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Command surface, mirroring the sections of www.fmind.dev."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Annotated, Any
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import RenderableType
|
|
11
|
+
|
|
12
|
+
from fmind import __version__, articles, render
|
|
13
|
+
from fmind.api import FmindError, load_article, load_profile
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="fmind",
|
|
17
|
+
help="Read Médéric Hurier's (Fmind) portfolio from the terminal. Every command renders the live "
|
|
18
|
+
"profile published at https://www.fmind.dev/api/profile.",
|
|
19
|
+
add_completion=False,
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
rich_markup_mode=None,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_STATE: dict[str, Any] = {"refresh": False, "json": False, "color": True}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _version(value: bool) -> None:
|
|
28
|
+
if value:
|
|
29
|
+
typer.echo(f"fmind {__version__}")
|
|
30
|
+
raise typer.Exit
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.callback()
|
|
34
|
+
def main(
|
|
35
|
+
refresh: Annotated[bool, typer.Option("--refresh", help="Ignore the cached copy and fetch it again.")] = False,
|
|
36
|
+
as_json: Annotated[bool, typer.Option("--json", help="Print the raw section as JSON instead of prose.")] = False,
|
|
37
|
+
color: Annotated[bool, typer.Option("--color/--no-color", help="Force or suppress ANSI colour.")] = True,
|
|
38
|
+
_v: Annotated[bool, typer.Option("--version", callback=_version, is_eager=True, help="Show the version.")] = False,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Store the global options for the section commands."""
|
|
41
|
+
_STATE.update(refresh=refresh, json=as_json, color=color)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _fail(message: str) -> typer.Exit:
|
|
45
|
+
"""Report a clear one-line error on stderr and end the command."""
|
|
46
|
+
typer.secho(f"fmind: {message}", fg="red", err=True)
|
|
47
|
+
return typer.Exit(code=1)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _doc() -> dict[str, Any]:
|
|
51
|
+
"""Load the profile, reporting a clear error instead of a traceback."""
|
|
52
|
+
try:
|
|
53
|
+
return load_profile(refresh=bool(_STATE["refresh"]))
|
|
54
|
+
except FmindError as error:
|
|
55
|
+
raise _fail(str(error)) from error
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _emit(body: RenderableType, payload: Any, *, banner: dict[str, Any] | None = None) -> None:
|
|
59
|
+
"""Print either the rendered section or its raw JSON."""
|
|
60
|
+
if _STATE["json"]:
|
|
61
|
+
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
|
|
62
|
+
sys.stdout.write("\n")
|
|
63
|
+
return
|
|
64
|
+
out = render.console(color=bool(_STATE["color"]))
|
|
65
|
+
if banner is not None:
|
|
66
|
+
out.print(render.banner(banner))
|
|
67
|
+
out.print()
|
|
68
|
+
out.print(body)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command()
|
|
72
|
+
def whoami() -> None:
|
|
73
|
+
"""Name, current mission, contact and availability."""
|
|
74
|
+
doc = _doc()
|
|
75
|
+
_emit(render.whoami(doc), {"metadata": doc["metadata"], "services": doc["services"]}, banner=doc)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command()
|
|
79
|
+
def about() -> None:
|
|
80
|
+
"""The biography, as published on the site."""
|
|
81
|
+
doc = _doc()
|
|
82
|
+
_emit(render.about(doc), doc["biography"])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command()
|
|
86
|
+
def skills() -> None:
|
|
87
|
+
"""Core expertise, laid out like a usage screen."""
|
|
88
|
+
doc = _doc()
|
|
89
|
+
_emit(render.skills(doc), doc["expertise"])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command()
|
|
93
|
+
def work() -> None:
|
|
94
|
+
"""Engagements, current one first."""
|
|
95
|
+
doc = _doc()
|
|
96
|
+
_emit(render.work(doc), doc["experience"])
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
def community() -> None:
|
|
101
|
+
"""Ambassador and advisory roles."""
|
|
102
|
+
doc = _doc()
|
|
103
|
+
_emit(render.community(doc), doc["leadership"])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@app.command()
|
|
107
|
+
def cert(
|
|
108
|
+
verify: Annotated[bool, typer.Option("--verify", help="List only credentials that are still active.")] = False,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Certifications, the PhD, and specializations."""
|
|
111
|
+
doc = _doc()
|
|
112
|
+
payload = [c for c in doc["certifications"] if c["active"]] if verify else doc["certifications"]
|
|
113
|
+
_emit(render.cert(doc, verify=verify), payload)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command()
|
|
117
|
+
def papers() -> None:
|
|
118
|
+
"""The doctorate and the peer-reviewed publications."""
|
|
119
|
+
doc = _doc()
|
|
120
|
+
_emit(render.papers(doc), {"thesis": doc["thesis"], "papers": doc["papers"]})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command()
|
|
124
|
+
def project(
|
|
125
|
+
top: Annotated[int, typer.Option("--top", min=1, max=50, help="How many projects to show.")] = 6,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Open-source repositories and video series."""
|
|
128
|
+
doc = _doc()
|
|
129
|
+
_emit(render.project(doc, top=top), (doc["open_source"] + doc["youtube_series"])[:top])
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.command()
|
|
133
|
+
def sites() -> None:
|
|
134
|
+
"""Interactive tools published alongside the writing."""
|
|
135
|
+
doc = _doc()
|
|
136
|
+
_emit(render.sites(doc), doc["site_pages"])
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@app.command()
|
|
140
|
+
def article(
|
|
141
|
+
limit: Annotated[int, typer.Option("--limit", min=1, max=200, help="How many articles to show.")] = 6,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""The most recent writing."""
|
|
144
|
+
doc = _doc()
|
|
145
|
+
posts = articles.latest(doc["articles"], limit=limit)
|
|
146
|
+
footer = f"{len(doc['articles'])} published · {doc['metadata']['site_url']}/articles/"
|
|
147
|
+
_emit(render.articles(posts, footer=footer), posts)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command()
|
|
151
|
+
def search(
|
|
152
|
+
query: Annotated[str, typer.Argument(help="Terms to match against titles, summaries, tags and slugs.")],
|
|
153
|
+
tag: Annotated[str | None, typer.Option("--tag", help="Restrict to one of the site's tags, such as Agent.")] = None,
|
|
154
|
+
limit: Annotated[int, typer.Option("--limit", min=1, max=200, help="How many matches to show.")] = 10,
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Find articles by term, newest match first."""
|
|
157
|
+
doc = _doc()
|
|
158
|
+
found = articles.search(doc["articles"], query, tag=tag, limit=limit)
|
|
159
|
+
footer = f"{len(found)} shown · {len(doc['articles'])} published · fmind read <slug> opens one"
|
|
160
|
+
_emit(render.articles(found, footer=footer), found)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command()
|
|
164
|
+
def read(
|
|
165
|
+
slug: Annotated[str, typer.Argument(help="Article slug, or enough of it to be unambiguous.")],
|
|
166
|
+
raw: Annotated[bool, typer.Option("--raw", help="Print the Markdown source instead of rendering it.")] = False,
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Read one article in the terminal, from its published Markdown."""
|
|
169
|
+
doc = _doc()
|
|
170
|
+
matches = articles.candidates(doc["articles"], slug.strip().lower())
|
|
171
|
+
if not matches:
|
|
172
|
+
raise _fail(f"no article matches {slug!r} — try: fmind search {slug}")
|
|
173
|
+
if len(matches) > 1:
|
|
174
|
+
listed = "\n ".join(matches[:10])
|
|
175
|
+
raise _fail(f"{slug!r} matches {len(matches)} articles:\n {listed}")
|
|
176
|
+
resolved = matches[0]
|
|
177
|
+
try:
|
|
178
|
+
markdown = load_article(resolved, refresh=bool(_STATE["refresh"]))
|
|
179
|
+
except FmindError as error:
|
|
180
|
+
raise _fail(str(error)) from error
|
|
181
|
+
post = articles.by_slug(doc["articles"], resolved)
|
|
182
|
+
if raw and not _STATE["json"]:
|
|
183
|
+
sys.stdout.write(markdown)
|
|
184
|
+
return
|
|
185
|
+
_emit(render.article_body(markdown), {"slug": resolved, "markdown": markdown, **(post or {})})
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@app.command()
|
|
189
|
+
def hire() -> None:
|
|
190
|
+
"""What can be booked right now."""
|
|
191
|
+
doc = _doc()
|
|
192
|
+
_emit(render.hire(doc), doc["services"])
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Render sections of the portfolio document as terminal output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rich.console import Console, Group, RenderableType
|
|
8
|
+
from rich.markdown import Markdown
|
|
9
|
+
from rich.padding import Padding
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
ACCENT = "bold #00ff41"
|
|
14
|
+
HI = "bold #ccffd9"
|
|
15
|
+
DIM = "#57a86e"
|
|
16
|
+
FLAG = "#7dffab"
|
|
17
|
+
ERR = "#ff7a5c"
|
|
18
|
+
|
|
19
|
+
WORDMARK = (
|
|
20
|
+
"█▀▀▀ █▄ ▄█ ▀█▀ █▄ █ █▀▀▄ ",
|
|
21
|
+
"█▄▄ █ ▀ █ █ █ █ █ █ █",
|
|
22
|
+
"█ █ █ █ █ ██ █ █",
|
|
23
|
+
"█ █ █ ▄█▄ █ █ █▄▄▀ ",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
SKILL_FLAGS = {
|
|
27
|
+
"Agentic Orchestration": "--agents",
|
|
28
|
+
"Production MLOps": "--mlops",
|
|
29
|
+
"Security-First AI": "--security",
|
|
30
|
+
"Technical Strategy": "--strategy",
|
|
31
|
+
"Data Science & ML": "--data",
|
|
32
|
+
"Python Development": "--python",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def console(*, color: bool = True) -> Console:
|
|
37
|
+
"""Build the output console; `color=False` yields plain text for pipes."""
|
|
38
|
+
return Console(no_color=not color, highlight=False, soft_wrap=False)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _rows(pairs: list[tuple[str, RenderableType]], key_width: int = 11) -> Table:
|
|
42
|
+
"""Lay out aligned key/value rows without visible borders."""
|
|
43
|
+
table = Table.grid(padding=(0, 2))
|
|
44
|
+
table.add_column(style=ACCENT, width=key_width, no_wrap=True)
|
|
45
|
+
table.add_column(overflow="fold")
|
|
46
|
+
for key, value in pairs:
|
|
47
|
+
table.add_row(key, value)
|
|
48
|
+
return table
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def banner(doc: dict[str, Any]) -> RenderableType:
|
|
52
|
+
"""The wordmark beside the name, role and headline."""
|
|
53
|
+
meta = doc["metadata"]
|
|
54
|
+
mark = Text("\n".join(WORDMARK), style=ACCENT)
|
|
55
|
+
identity = Group(
|
|
56
|
+
Text(meta["name"], style=HI),
|
|
57
|
+
Text(meta["job_title"], style="#00ff41"),
|
|
58
|
+
Text(meta["headline_primary"], style=DIM),
|
|
59
|
+
)
|
|
60
|
+
side = Table.grid(padding=(0, 3))
|
|
61
|
+
side.add_column(no_wrap=True)
|
|
62
|
+
side.add_column(overflow="fold")
|
|
63
|
+
side.add_row(mark, identity)
|
|
64
|
+
return side
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def whoami(doc: dict[str, Any]) -> RenderableType:
|
|
68
|
+
"""Identity, current mission, contact and availability."""
|
|
69
|
+
meta = doc["metadata"]
|
|
70
|
+
experience = doc.get("experience") or []
|
|
71
|
+
mission = Text("—", style=DIM)
|
|
72
|
+
if experience:
|
|
73
|
+
current = experience[0]
|
|
74
|
+
mission = Text(f"{current['company']} — {current['title']}", style="#74e492")
|
|
75
|
+
services = doc.get("services") or []
|
|
76
|
+
status = Text()
|
|
77
|
+
for index, service in enumerate(services):
|
|
78
|
+
open_now = service.get("badge_type") != "error"
|
|
79
|
+
if index:
|
|
80
|
+
status.append("\n")
|
|
81
|
+
status.append("● ", style="#00ff41" if open_now else ERR)
|
|
82
|
+
status.append(service["title"], style=HI)
|
|
83
|
+
status.append(f" — {service['badge']}", style=DIM)
|
|
84
|
+
return _rows(
|
|
85
|
+
[
|
|
86
|
+
("Name", Text(f"{meta['name']} ({meta['alternate_name']})", style=HI)),
|
|
87
|
+
("Role", Text(meta["job_title"], style="#74e492")),
|
|
88
|
+
("Mission", mission),
|
|
89
|
+
("Degree", Text(doc["thesis"]["institution_details"], style="#74e492")),
|
|
90
|
+
("Contact", Text(meta["email"], style=FLAG)),
|
|
91
|
+
("Website", Text(meta["site_url"], style=FLAG)),
|
|
92
|
+
("Status", status),
|
|
93
|
+
]
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def about(doc: dict[str, Any]) -> RenderableType:
|
|
98
|
+
"""The biography paragraphs."""
|
|
99
|
+
return Group(*(Padding(Text(p, style="#74e492"), (0, 0, 1, 0)) for p in doc["biography"]))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def skills(doc: dict[str, Any]) -> RenderableType:
|
|
103
|
+
"""Expertise laid out like a usage screen, matching www.fmind.dev."""
|
|
104
|
+
flags = [SKILL_FLAGS.get(card["title"], "--" + card["title"].split()[0].lower()) for card in doc["expertise"]]
|
|
105
|
+
synopsis = Text("fmind", style=ACCENT)
|
|
106
|
+
synopsis.append(" skills ", style=HI)
|
|
107
|
+
synopsis.append(" ".join(f"[{flag}]" for flag in flags), style=FLAG)
|
|
108
|
+
table = Table.grid(padding=(0, 2))
|
|
109
|
+
table.add_column(style=FLAG, width=12, no_wrap=True)
|
|
110
|
+
table.add_column(overflow="fold")
|
|
111
|
+
for card, flag in zip(doc["expertise"], flags, strict=True):
|
|
112
|
+
body = Text(card["title"], style=HI)
|
|
113
|
+
body.append(f" — {card['description']}", style=DIM)
|
|
114
|
+
table.add_row(flag, body)
|
|
115
|
+
return Group(
|
|
116
|
+
Text("USAGE", style=DIM),
|
|
117
|
+
Padding(synopsis, (0, 0, 1, 2)),
|
|
118
|
+
Text("OPTIONS", style=DIM),
|
|
119
|
+
Padding(table, (0, 0, 0, 2)),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def work(doc: dict[str, Any]) -> RenderableType:
|
|
124
|
+
"""Engagements, current one first."""
|
|
125
|
+
blocks: list[RenderableType] = []
|
|
126
|
+
for index, job in enumerate(doc["experience"]):
|
|
127
|
+
head = Text(job["company"].upper(), style=ACCENT)
|
|
128
|
+
if index == 0:
|
|
129
|
+
head.append(" · current", style=DIM)
|
|
130
|
+
body = Group(
|
|
131
|
+
head,
|
|
132
|
+
Text(job["title"], style=HI),
|
|
133
|
+
Text(job["description"], style=DIM),
|
|
134
|
+
Text(" ".join(job["tags"]), style=FLAG),
|
|
135
|
+
)
|
|
136
|
+
blocks.append(Padding(body, (0, 0, 1, 0)))
|
|
137
|
+
return Group(*blocks)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def community(doc: dict[str, Any]) -> RenderableType:
|
|
141
|
+
"""Ambassador and advisory roles."""
|
|
142
|
+
return _rows(
|
|
143
|
+
[
|
|
144
|
+
(r["organization"][:11], Group(Text(r["role"], style=HI), Text(r["description"], style=DIM)))
|
|
145
|
+
for r in doc["leadership"]
|
|
146
|
+
],
|
|
147
|
+
key_width=13,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def cert(doc: dict[str, Any], *, verify: bool = False) -> RenderableType:
|
|
152
|
+
"""Credentials, plus specializations unless `verify` narrows to active ones."""
|
|
153
|
+
table = Table.grid(padding=(0, 2))
|
|
154
|
+
table.add_column(width=8, no_wrap=True)
|
|
155
|
+
table.add_column(overflow="fold")
|
|
156
|
+
for badge in doc["certifications"]:
|
|
157
|
+
active = bool(badge["active"])
|
|
158
|
+
if verify and not active:
|
|
159
|
+
continue
|
|
160
|
+
state = Text("active" if active else "expired", style="#00ff41" if active else DIM)
|
|
161
|
+
body = Text(badge["title"], style=HI if active else "#74e492")
|
|
162
|
+
body.append(f" — {badge['issuer']}", style=DIM)
|
|
163
|
+
table.add_row(state, body)
|
|
164
|
+
thesis = Text(doc["thesis"]["title"], style=HI)
|
|
165
|
+
thesis.append(f" — {doc['thesis']['institution_details']}", style=DIM)
|
|
166
|
+
parts: list[RenderableType] = [table, Padding(Group(Text("PhD", style=ACCENT), thesis), (1, 0, 0, 0))]
|
|
167
|
+
if not verify:
|
|
168
|
+
specs = Text("\n".join(f" {s['title']} — {s['issuer_details']}" for s in doc["specializations"]), style=DIM)
|
|
169
|
+
parts.append(Padding(Group(Text("SPECIALIZATIONS", style=DIM), specs), (1, 0, 0, 0)))
|
|
170
|
+
return Group(*parts)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def project(doc: dict[str, Any], *, top: int = 6) -> RenderableType:
|
|
174
|
+
"""Open-source repositories and video series."""
|
|
175
|
+
items = [(p["title"], p["description"], p["href"]) for p in doc["open_source"]]
|
|
176
|
+
items += [(v["title"], v["description"], v["url"]) for v in doc["youtube_series"]]
|
|
177
|
+
blocks = [
|
|
178
|
+
Padding(Group(Text(title, style=HI), Text(description, style=DIM), Text(url, style=FLAG)), (0, 0, 1, 0))
|
|
179
|
+
for title, description, url in items[: max(top, 0)]
|
|
180
|
+
]
|
|
181
|
+
return Group(*blocks)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def articles(posts: list[dict[str, Any]], *, footer: str = "") -> RenderableType:
|
|
185
|
+
"""A list of articles, one block each, with `fmind read` slugs kept visible."""
|
|
186
|
+
blocks: list[RenderableType] = []
|
|
187
|
+
for post in posts:
|
|
188
|
+
head = Text(post["date"][:10], style=ACCENT)
|
|
189
|
+
head.append(f" {post['reading_minutes']} min", style=DIM)
|
|
190
|
+
head.append(f" {' '.join(post['tags'])}", style=FLAG)
|
|
191
|
+
body = Text("fmind read ", style=DIM)
|
|
192
|
+
body.append(post["slug"], style=FLAG)
|
|
193
|
+
blocks.append(Padding(Group(head, Text(post["title"], style=HI), body), (0, 0, 1, 0)))
|
|
194
|
+
if not blocks:
|
|
195
|
+
return Text("no article matches", style=DIM)
|
|
196
|
+
return Group(*blocks, Text(footer, style=DIM)) if footer else Group(*blocks)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def article_body(markdown: str) -> RenderableType:
|
|
200
|
+
"""One article, rendered from its published Markdown source.
|
|
201
|
+
|
|
202
|
+
The source already opens with the title, summary, date, reading time, tags and
|
|
203
|
+
canonical URL, so nothing is prepended here.
|
|
204
|
+
"""
|
|
205
|
+
return Markdown(markdown, code_theme="ansi_dark", hyperlinks=False)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def papers(doc: dict[str, Any]) -> RenderableType:
|
|
209
|
+
"""The doctorate and the peer-reviewed record behind it."""
|
|
210
|
+
thesis = doc["thesis"]
|
|
211
|
+
head = Group(
|
|
212
|
+
Text("PhD", style=ACCENT),
|
|
213
|
+
Text(thesis["title"], style=HI),
|
|
214
|
+
Text(thesis["institution_details"], style="#74e492"),
|
|
215
|
+
Text(thesis["description"], style=DIM),
|
|
216
|
+
Text(thesis["url"], style=FLAG),
|
|
217
|
+
)
|
|
218
|
+
parts: list[RenderableType] = [Padding(head, (0, 0, 1, 0))]
|
|
219
|
+
for link in thesis.get("links") or []:
|
|
220
|
+
row = Text(" ", style=DIM)
|
|
221
|
+
row.append(link["label"], style="#74e492")
|
|
222
|
+
row.append(f" {link['url']}", style=FLAG)
|
|
223
|
+
parts.append(row)
|
|
224
|
+
if thesis.get("links"):
|
|
225
|
+
parts.append(Text())
|
|
226
|
+
parts.append(Text("PUBLICATIONS", style=ACCENT))
|
|
227
|
+
for paper in doc["papers"]:
|
|
228
|
+
body = Group(
|
|
229
|
+
Text(paper["title"], style=HI),
|
|
230
|
+
Text(paper["venue"], style=DIM),
|
|
231
|
+
Text(paper["url"], style=FLAG),
|
|
232
|
+
)
|
|
233
|
+
parts.append(Padding(body, (1, 0, 0, 0)))
|
|
234
|
+
return Group(*parts)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def sites(doc: dict[str, Any]) -> RenderableType:
|
|
238
|
+
"""Interactive tools published alongside the writing."""
|
|
239
|
+
blocks = [
|
|
240
|
+
Padding(
|
|
241
|
+
Group(
|
|
242
|
+
Text(page["title"], style=HI),
|
|
243
|
+
Text(page["description"], style=DIM),
|
|
244
|
+
Text(f"for {page['audience']}", style="#74e492"),
|
|
245
|
+
Text(page["url"], style=FLAG),
|
|
246
|
+
),
|
|
247
|
+
(0, 0, 1, 0),
|
|
248
|
+
)
|
|
249
|
+
for page in doc["site_pages"]
|
|
250
|
+
]
|
|
251
|
+
return Group(*blocks) if blocks else Text("no interactive site published", style=DIM)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def hire(doc: dict[str, Any]) -> RenderableType:
|
|
255
|
+
"""What can be booked right now."""
|
|
256
|
+
blocks: list[RenderableType] = []
|
|
257
|
+
for service in doc["services"]:
|
|
258
|
+
open_now = service.get("badge_type") != "error"
|
|
259
|
+
state = Text("● ", style="#00ff41" if open_now else ERR)
|
|
260
|
+
state.append(service["badge"], style="#00ff41" if open_now else ERR)
|
|
261
|
+
blocks.append(
|
|
262
|
+
Padding(
|
|
263
|
+
Group(
|
|
264
|
+
Text(service["title"], style=HI),
|
|
265
|
+
Text(service["description"], style=DIM),
|
|
266
|
+
state,
|
|
267
|
+
Text(service["cta_url"], style=FLAG),
|
|
268
|
+
),
|
|
269
|
+
(0, 0, 1, 0),
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
return Group(*blocks)
|