simple-resume 0.1.9__py3-none-any.whl
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.
- simple_resume/__init__.py +132 -0
- simple_resume/core/__init__.py +47 -0
- simple_resume/core/colors.py +215 -0
- simple_resume/core/config.py +672 -0
- simple_resume/core/constants/__init__.py +207 -0
- simple_resume/core/constants/colors.py +98 -0
- simple_resume/core/constants/files.py +28 -0
- simple_resume/core/constants/layout.py +58 -0
- simple_resume/core/dependencies.py +258 -0
- simple_resume/core/effects.py +154 -0
- simple_resume/core/exceptions.py +261 -0
- simple_resume/core/file_operations.py +68 -0
- simple_resume/core/generate/__init__.py +21 -0
- simple_resume/core/generate/exceptions.py +69 -0
- simple_resume/core/generate/html.py +233 -0
- simple_resume/core/generate/pdf.py +659 -0
- simple_resume/core/generate/plan.py +131 -0
- simple_resume/core/hydration.py +55 -0
- simple_resume/core/importers/__init__.py +3 -0
- simple_resume/core/importers/json_resume.py +284 -0
- simple_resume/core/latex/__init__.py +60 -0
- simple_resume/core/latex/context.py +56 -0
- simple_resume/core/latex/conversion.py +227 -0
- simple_resume/core/latex/escaping.py +68 -0
- simple_resume/core/latex/fonts.py +93 -0
- simple_resume/core/latex/formatting.py +81 -0
- simple_resume/core/latex/sections.py +218 -0
- simple_resume/core/latex/types.py +84 -0
- simple_resume/core/markdown.py +127 -0
- simple_resume/core/models.py +102 -0
- simple_resume/core/palettes/__init__.py +38 -0
- simple_resume/core/palettes/common.py +73 -0
- simple_resume/core/palettes/data/default_palettes.json +58 -0
- simple_resume/core/palettes/exceptions.py +33 -0
- simple_resume/core/palettes/fetch_types.py +52 -0
- simple_resume/core/palettes/generators.py +137 -0
- simple_resume/core/palettes/registry.py +76 -0
- simple_resume/core/palettes/resolution.py +123 -0
- simple_resume/core/palettes/sources.py +162 -0
- simple_resume/core/paths.py +21 -0
- simple_resume/core/protocols.py +134 -0
- simple_resume/core/py.typed +0 -0
- simple_resume/core/render/__init__.py +37 -0
- simple_resume/core/render/manage.py +199 -0
- simple_resume/core/render/plan.py +405 -0
- simple_resume/core/result.py +226 -0
- simple_resume/core/resume.py +609 -0
- simple_resume/core/skills.py +60 -0
- simple_resume/core/validation.py +321 -0
- simple_resume/py.typed +0 -0
- simple_resume/shell/__init__.py +3 -0
- simple_resume/shell/assets/static/css/README.md +213 -0
- simple_resume/shell/assets/static/css/common.css +641 -0
- simple_resume/shell/assets/static/css/fonts.css +42 -0
- simple_resume/shell/assets/static/css/preview.css +82 -0
- simple_resume/shell/assets/static/css/print.css +99 -0
- simple_resume/shell/assets/static/fonts/AvenirLTStd-Book.otf +0 -0
- simple_resume/shell/assets/static/fonts/AvenirLTStd-Light.otf +0 -0
- simple_resume/shell/assets/static/fonts/AvenirLTStd-Medium.otf +0 -0
- simple_resume/shell/assets/static/fonts/AvenirLTStd-Oblique.otf +0 -0
- simple_resume/shell/assets/static/fonts/AvenirLTStd-Roman.otf +0 -0
- simple_resume/shell/assets/static/fonts/fontawesome/Font Awesome 6 Brands-Regular-400.otf +0 -0
- simple_resume/shell/assets/static/fonts/fontawesome/Font Awesome 6 Free-Solid-900.otf +0 -0
- simple_resume/shell/assets/static/images/default_profile_1.jpg +0 -0
- simple_resume/shell/assets/static/images/default_profile_2.png +0 -0
- simple_resume/shell/assets/static/schema.json +236 -0
- simple_resume/shell/assets/static/themes/README.md +208 -0
- simple_resume/shell/assets/static/themes/bold.yaml +64 -0
- simple_resume/shell/assets/static/themes/classic.yaml +64 -0
- simple_resume/shell/assets/static/themes/executive.yaml +64 -0
- simple_resume/shell/assets/static/themes/minimal.yaml +64 -0
- simple_resume/shell/assets/static/themes/modern.yaml +64 -0
- simple_resume/shell/assets/templates/html/cover.html +129 -0
- simple_resume/shell/assets/templates/html/demo.html +13 -0
- simple_resume/shell/assets/templates/html/resume_base.html +453 -0
- simple_resume/shell/assets/templates/html/resume_no_bars.html +316 -0
- simple_resume/shell/assets/templates/html/resume_with_bars.html +362 -0
- simple_resume/shell/cli/__init__.py +35 -0
- simple_resume/shell/cli/main.py +975 -0
- simple_resume/shell/cli/palette.py +75 -0
- simple_resume/shell/cli/random_palette_demo.py +407 -0
- simple_resume/shell/config.py +96 -0
- simple_resume/shell/effect_executor.py +211 -0
- simple_resume/shell/file_opener.py +308 -0
- simple_resume/shell/generate/__init__.py +37 -0
- simple_resume/shell/generate/core.py +650 -0
- simple_resume/shell/generate/lazy.py +284 -0
- simple_resume/shell/io_utils.py +199 -0
- simple_resume/shell/palettes/__init__.py +1 -0
- simple_resume/shell/palettes/fetch.py +63 -0
- simple_resume/shell/palettes/loader.py +321 -0
- simple_resume/shell/palettes/remote.py +179 -0
- simple_resume/shell/pdf_executor.py +52 -0
- simple_resume/shell/py.typed +0 -0
- simple_resume/shell/render/__init__.py +1 -0
- simple_resume/shell/render/latex.py +308 -0
- simple_resume/shell/render/operations.py +240 -0
- simple_resume/shell/resume_extensions.py +737 -0
- simple_resume/shell/runtime/__init__.py +7 -0
- simple_resume/shell/runtime/content.py +190 -0
- simple_resume/shell/runtime/generate.py +497 -0
- simple_resume/shell/runtime/lazy.py +138 -0
- simple_resume/shell/runtime/lazy_import.py +173 -0
- simple_resume/shell/service_locator.py +80 -0
- simple_resume/shell/services.py +256 -0
- simple_resume/shell/session/__init__.py +6 -0
- simple_resume/shell/session/config.py +35 -0
- simple_resume/shell/session/manage.py +386 -0
- simple_resume/shell/strategies.py +181 -0
- simple_resume/shell/themes/__init__.py +35 -0
- simple_resume/shell/themes/loader.py +230 -0
- simple_resume-0.1.9.dist-info/METADATA +201 -0
- simple_resume-0.1.9.dist-info/RECORD +116 -0
- simple_resume-0.1.9.dist-info/WHEEL +4 -0
- simple_resume-0.1.9.dist-info/entry_points.txt +5 -0
- simple_resume-0.1.9.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Provide pure generation planning helpers for CLI and session shells."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from simple_resume.core.constants import OutputFormat
|
|
13
|
+
from simple_resume.core.models import GenerationConfig
|
|
14
|
+
from simple_resume.core.paths import Paths
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CommandType(str, Enum):
|
|
18
|
+
"""Define kinds of generation commands the shell can execute."""
|
|
19
|
+
|
|
20
|
+
SINGLE = "single"
|
|
21
|
+
BATCH_SINGLE = "batch_single"
|
|
22
|
+
BATCH_ALL = "batch_all"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class GenerationCommand:
|
|
27
|
+
"""Define a pure description of a generation step."""
|
|
28
|
+
|
|
29
|
+
kind: CommandType
|
|
30
|
+
format: OutputFormat | None
|
|
31
|
+
config: GenerationConfig
|
|
32
|
+
overrides: dict[str, Any]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class GeneratePlanOptions:
|
|
37
|
+
"""Define normalized inputs for planning CLI/session work."""
|
|
38
|
+
|
|
39
|
+
name: str | None
|
|
40
|
+
data_dir: Path | None
|
|
41
|
+
template: str | None
|
|
42
|
+
output_path: Path | None
|
|
43
|
+
output_dir: Path | None
|
|
44
|
+
preview: bool
|
|
45
|
+
open_after: bool
|
|
46
|
+
browser: str | None
|
|
47
|
+
formats: Sequence[OutputFormat]
|
|
48
|
+
overrides: dict[str, Any]
|
|
49
|
+
paths: Paths | None = None
|
|
50
|
+
pattern: str = "*"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_generation_plan(options: GeneratePlanOptions) -> list[GenerationCommand]:
|
|
54
|
+
"""Return the deterministic commands needed to satisfy the request."""
|
|
55
|
+
if not options.formats:
|
|
56
|
+
raise ValueError("At least one output format must be specified")
|
|
57
|
+
|
|
58
|
+
plan: list[GenerationCommand] = []
|
|
59
|
+
overrides = copy.deepcopy(options.overrides)
|
|
60
|
+
|
|
61
|
+
if options.name:
|
|
62
|
+
for format_type in options.formats:
|
|
63
|
+
plan.append(
|
|
64
|
+
GenerationCommand(
|
|
65
|
+
kind=CommandType.SINGLE,
|
|
66
|
+
format=format_type,
|
|
67
|
+
config=GenerationConfig(
|
|
68
|
+
name=options.name,
|
|
69
|
+
data_dir=options.data_dir,
|
|
70
|
+
template=options.template,
|
|
71
|
+
format=format_type,
|
|
72
|
+
output_path=options.output_path,
|
|
73
|
+
open_after=options.open_after,
|
|
74
|
+
preview=options.preview,
|
|
75
|
+
browser=options.browser,
|
|
76
|
+
paths=options.paths,
|
|
77
|
+
pattern=options.pattern,
|
|
78
|
+
),
|
|
79
|
+
overrides=copy.deepcopy(overrides),
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
return plan
|
|
83
|
+
|
|
84
|
+
if len(options.formats) == 1:
|
|
85
|
+
format_type = options.formats[0]
|
|
86
|
+
plan.append(
|
|
87
|
+
GenerationCommand(
|
|
88
|
+
kind=CommandType.BATCH_SINGLE,
|
|
89
|
+
format=format_type,
|
|
90
|
+
config=GenerationConfig(
|
|
91
|
+
data_dir=options.data_dir,
|
|
92
|
+
template=options.template,
|
|
93
|
+
output_dir=options.output_dir,
|
|
94
|
+
open_after=options.open_after,
|
|
95
|
+
preview=options.preview,
|
|
96
|
+
browser=options.browser,
|
|
97
|
+
paths=options.paths,
|
|
98
|
+
pattern=options.pattern,
|
|
99
|
+
),
|
|
100
|
+
overrides=copy.deepcopy(overrides),
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
return plan
|
|
104
|
+
|
|
105
|
+
plan.append(
|
|
106
|
+
GenerationCommand(
|
|
107
|
+
kind=CommandType.BATCH_ALL,
|
|
108
|
+
format=None,
|
|
109
|
+
config=GenerationConfig(
|
|
110
|
+
data_dir=options.data_dir,
|
|
111
|
+
template=options.template,
|
|
112
|
+
output_dir=options.output_dir,
|
|
113
|
+
open_after=options.open_after,
|
|
114
|
+
preview=options.preview,
|
|
115
|
+
browser=options.browser,
|
|
116
|
+
formats=list(options.formats),
|
|
117
|
+
paths=options.paths,
|
|
118
|
+
pattern=options.pattern,
|
|
119
|
+
),
|
|
120
|
+
overrides=copy.deepcopy(overrides),
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
return plan
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
__all__ = [
|
|
127
|
+
"CommandType",
|
|
128
|
+
"GenerationCommand",
|
|
129
|
+
"GeneratePlanOptions",
|
|
130
|
+
"build_generation_plan",
|
|
131
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Provide pure helpers for transforming hydrated resume data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
from simple_resume.core.skills import format_skill_groups
|
|
10
|
+
|
|
11
|
+
NormalizeConfigFn = Callable[
|
|
12
|
+
[dict[str, Any], str], tuple[dict[str, Any], dict[str, Any] | None]
|
|
13
|
+
]
|
|
14
|
+
RenderMarkdownFn = Callable[[dict[str, Any]], dict[str, Any]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_skill_group_payload(resume_data: Mapping[str, Any]) -> dict[str, Any]:
|
|
18
|
+
"""Return the computed skill group payload for sidebar sections."""
|
|
19
|
+
return {
|
|
20
|
+
"expertise_groups": format_skill_groups(resume_data.get("expertise")),
|
|
21
|
+
"programming_groups": format_skill_groups(resume_data.get("programming")),
|
|
22
|
+
"keyskills_groups": format_skill_groups(resume_data.get("keyskills")),
|
|
23
|
+
"certification_groups": format_skill_groups(resume_data.get("certification")),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def hydrate_resume_structure(
|
|
28
|
+
source_yaml: dict[str, Any],
|
|
29
|
+
*,
|
|
30
|
+
filename: str = "",
|
|
31
|
+
transform_markdown: bool = True,
|
|
32
|
+
normalize_config_fn: NormalizeConfigFn,
|
|
33
|
+
render_markdown_fn: RenderMarkdownFn,
|
|
34
|
+
) -> dict[str, Any]:
|
|
35
|
+
"""Return normalized resume data using injected pure helpers."""
|
|
36
|
+
processed_resume = copy.deepcopy(source_yaml)
|
|
37
|
+
|
|
38
|
+
config = processed_resume.get("config")
|
|
39
|
+
if isinstance(config, dict):
|
|
40
|
+
normalized_config, palette_meta = normalize_config_fn(config, filename)
|
|
41
|
+
processed_resume["config"] = normalized_config
|
|
42
|
+
if palette_meta:
|
|
43
|
+
meta = dict(processed_resume.get("meta", {}))
|
|
44
|
+
meta["palette"] = palette_meta
|
|
45
|
+
processed_resume["meta"] = meta
|
|
46
|
+
|
|
47
|
+
if transform_markdown:
|
|
48
|
+
processed_resume = render_markdown_fn(processed_resume)
|
|
49
|
+
else:
|
|
50
|
+
processed_resume.update(build_skill_group_payload(processed_resume))
|
|
51
|
+
|
|
52
|
+
return processed_resume
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = ["build_skill_group_payload", "hydrate_resume_structure"]
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Import helpers for the JSON Resume (jsonresume.org) open resume format.
|
|
2
|
+
|
|
3
|
+
This module intentionally does *not* attempt a 1:1 mapping of every JSON Resume
|
|
4
|
+
field to simple-resume. Instead, it provides a pragmatic conversion that:
|
|
5
|
+
|
|
6
|
+
- Produces a valid simple-resume payload (including a non-empty ``config`` block)
|
|
7
|
+
- Preserves key content (basics, work, education, projects)
|
|
8
|
+
- Uses markdown-friendly formatting for highlights
|
|
9
|
+
|
|
10
|
+
Schema reference (v1.0.x): https://github.com/jsonresume/resume-schema
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def looks_like_json_resume(payload: Any) -> bool:
|
|
20
|
+
"""Return True if payload resembles a JSON Resume document."""
|
|
21
|
+
if not isinstance(payload, Mapping):
|
|
22
|
+
return False
|
|
23
|
+
basics = payload.get("basics")
|
|
24
|
+
return isinstance(basics, Mapping) and "full_name" not in payload
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _strip_url_prefix(url: str, prefix: str) -> str:
|
|
28
|
+
if url.startswith(prefix):
|
|
29
|
+
return url[len(prefix) :].lstrip("/")
|
|
30
|
+
return url
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _to_markdown_bullets(items: Any) -> str:
|
|
34
|
+
if not items:
|
|
35
|
+
return ""
|
|
36
|
+
if isinstance(items, str):
|
|
37
|
+
return items
|
|
38
|
+
if not isinstance(items, list):
|
|
39
|
+
return str(items)
|
|
40
|
+
cleaned = [str(item).strip() for item in items if item]
|
|
41
|
+
if not cleaned:
|
|
42
|
+
return ""
|
|
43
|
+
return "\n".join(f"- {line}" for line in cleaned)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _join_nonempty(*parts: Any, sep: str = "\n\n") -> str:
|
|
47
|
+
chunks = [str(p).strip() for p in parts if isinstance(p, str) and p.strip()]
|
|
48
|
+
return sep.join(chunks)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _get_str(data: Mapping[str, Any], key: str) -> str | None:
|
|
52
|
+
"""Extract a non-empty stripped string or return None."""
|
|
53
|
+
val = data.get(key)
|
|
54
|
+
if isinstance(val, str) and val.strip():
|
|
55
|
+
return val.strip()
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _convert_basics_simple(basics: Mapping[str, Any], result: dict[str, Any]) -> None:
|
|
60
|
+
"""Extract simple scalar fields from basics into result."""
|
|
61
|
+
field_map = {
|
|
62
|
+
"name": "full_name",
|
|
63
|
+
"email": "email",
|
|
64
|
+
"phone": "phone",
|
|
65
|
+
"url": "web",
|
|
66
|
+
"image": "image_uri",
|
|
67
|
+
"label": "headline",
|
|
68
|
+
"summary": "description",
|
|
69
|
+
}
|
|
70
|
+
for src_key, dst_key in field_map.items():
|
|
71
|
+
val = _get_str(basics, src_key)
|
|
72
|
+
if val:
|
|
73
|
+
result[dst_key] = val
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _convert_location(basics: Mapping[str, Any], result: dict[str, Any]) -> None:
|
|
77
|
+
"""Extract location from basics into result['address']."""
|
|
78
|
+
location = basics.get("location")
|
|
79
|
+
if not isinstance(location, Mapping):
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
address_lines: list[str] = []
|
|
83
|
+
addr = _get_str(location, "address")
|
|
84
|
+
if addr:
|
|
85
|
+
address_lines.append(addr)
|
|
86
|
+
|
|
87
|
+
city, region, postal = (
|
|
88
|
+
location.get("city"),
|
|
89
|
+
location.get("region"),
|
|
90
|
+
location.get("postalCode"),
|
|
91
|
+
)
|
|
92
|
+
line2_parts = [
|
|
93
|
+
str(x).strip()
|
|
94
|
+
for x in (city, region, postal)
|
|
95
|
+
if isinstance(x, str) and x.strip()
|
|
96
|
+
]
|
|
97
|
+
if line2_parts:
|
|
98
|
+
address_lines.append(" ".join(line2_parts))
|
|
99
|
+
|
|
100
|
+
country = _get_str(location, "countryCode")
|
|
101
|
+
if country:
|
|
102
|
+
address_lines.append(country)
|
|
103
|
+
|
|
104
|
+
if address_lines:
|
|
105
|
+
result["address"] = address_lines
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _convert_profiles(basics: Mapping[str, Any], result: dict[str, Any]) -> None:
|
|
109
|
+
"""Extract linkedin/github from profiles into result."""
|
|
110
|
+
profiles = basics.get("profiles")
|
|
111
|
+
if not isinstance(profiles, list):
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
for profile in profiles:
|
|
115
|
+
if not isinstance(profile, Mapping):
|
|
116
|
+
continue
|
|
117
|
+
network = str(profile.get("network", "")).strip().lower()
|
|
118
|
+
purl = _get_str(profile, "url")
|
|
119
|
+
username = _get_str(profile, "username")
|
|
120
|
+
|
|
121
|
+
if network == "linkedin":
|
|
122
|
+
if purl:
|
|
123
|
+
result["linkedin"] = _strip_url_prefix(
|
|
124
|
+
purl, "https://www.linkedin.com/"
|
|
125
|
+
)
|
|
126
|
+
elif username:
|
|
127
|
+
result["linkedin"] = username
|
|
128
|
+
elif network == "github":
|
|
129
|
+
if purl:
|
|
130
|
+
result["github"] = _strip_url_prefix(purl, "https://github.com/")
|
|
131
|
+
elif username:
|
|
132
|
+
result["github"] = username
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _convert_work(payload: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
136
|
+
"""Convert work entries to simple-resume format."""
|
|
137
|
+
work = payload.get("work")
|
|
138
|
+
if not isinstance(work, list):
|
|
139
|
+
return []
|
|
140
|
+
|
|
141
|
+
entries: list[dict[str, Any]] = []
|
|
142
|
+
for item in work:
|
|
143
|
+
if not isinstance(item, Mapping):
|
|
144
|
+
continue
|
|
145
|
+
highlights = _to_markdown_bullets(item.get("highlights"))
|
|
146
|
+
raw_summary = item.get("summary")
|
|
147
|
+
summary_str = str(raw_summary).strip() if raw_summary else ""
|
|
148
|
+
desc = _join_nonempty(summary_str, highlights)
|
|
149
|
+
entries.append(
|
|
150
|
+
{
|
|
151
|
+
"start": item.get("startDate") or "",
|
|
152
|
+
"end": item.get("endDate") or "",
|
|
153
|
+
"title": item.get("position") or "",
|
|
154
|
+
"company": item.get("name") or "",
|
|
155
|
+
"company_link": item.get("url") or "",
|
|
156
|
+
"description": desc or "",
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
return entries
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _convert_education(payload: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
163
|
+
"""Convert education entries to simple-resume format."""
|
|
164
|
+
education = payload.get("education")
|
|
165
|
+
if not isinstance(education, list):
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
entries: list[dict[str, Any]] = []
|
|
169
|
+
for item in education:
|
|
170
|
+
if not isinstance(item, Mapping):
|
|
171
|
+
continue
|
|
172
|
+
study = str(item.get("studyType", "")).strip()
|
|
173
|
+
area = str(item.get("area", "")).strip()
|
|
174
|
+
title = " ".join(x for x in (study, area) if x)
|
|
175
|
+
|
|
176
|
+
desc_parts: list[str] = []
|
|
177
|
+
gpa = item.get("gpa")
|
|
178
|
+
if gpa:
|
|
179
|
+
desc_parts.append(f"GPA: {gpa}")
|
|
180
|
+
courses_md = _to_markdown_bullets(item.get("courses"))
|
|
181
|
+
if courses_md:
|
|
182
|
+
desc_parts.append("Courses:\n" + courses_md)
|
|
183
|
+
|
|
184
|
+
entries.append(
|
|
185
|
+
{
|
|
186
|
+
"start": item.get("startDate") or "",
|
|
187
|
+
"end": item.get("endDate") or "",
|
|
188
|
+
"title": title,
|
|
189
|
+
"company": item.get("institution") or "",
|
|
190
|
+
"description": _join_nonempty(*desc_parts) or "",
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
return entries
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _convert_projects(payload: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
197
|
+
"""Convert project entries to simple-resume format."""
|
|
198
|
+
projects = payload.get("projects")
|
|
199
|
+
if not isinstance(projects, list):
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
entries: list[dict[str, Any]] = []
|
|
203
|
+
for item in projects:
|
|
204
|
+
if not isinstance(item, Mapping):
|
|
205
|
+
continue
|
|
206
|
+
highlights = _to_markdown_bullets(item.get("highlights"))
|
|
207
|
+
raw_desc = item.get("description")
|
|
208
|
+
desc_str = str(raw_desc).strip() if raw_desc else ""
|
|
209
|
+
desc = _join_nonempty(desc_str, highlights)
|
|
210
|
+
entries.append(
|
|
211
|
+
{
|
|
212
|
+
"start": item.get("startDate") or "",
|
|
213
|
+
"end": item.get("endDate") or "",
|
|
214
|
+
"title": item.get("name") or "",
|
|
215
|
+
"title_link": item.get("url") or "",
|
|
216
|
+
"company": item.get("entity") or "",
|
|
217
|
+
"description": desc or "",
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
return entries
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _convert_skills(payload: Mapping[str, Any], result: dict[str, Any]) -> None:
|
|
224
|
+
"""Extract skills into expertise and keyskills."""
|
|
225
|
+
skills = payload.get("skills")
|
|
226
|
+
if not isinstance(skills, list):
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
expertise: list[str] = []
|
|
230
|
+
keyskills: list[str] = []
|
|
231
|
+
for group in skills:
|
|
232
|
+
if not isinstance(group, Mapping):
|
|
233
|
+
continue
|
|
234
|
+
group_name = _get_str(group, "name")
|
|
235
|
+
if group_name:
|
|
236
|
+
expertise.append(group_name)
|
|
237
|
+
keywords = group.get("keywords")
|
|
238
|
+
if isinstance(keywords, list):
|
|
239
|
+
for kw in keywords:
|
|
240
|
+
if isinstance(kw, str) and kw.strip():
|
|
241
|
+
keyskills.append(kw.strip())
|
|
242
|
+
|
|
243
|
+
if expertise:
|
|
244
|
+
result["expertise"] = sorted(set(expertise))
|
|
245
|
+
if keyskills:
|
|
246
|
+
result["keyskills"] = sorted(set(keyskills))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def json_resume_to_simple_resume(payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
250
|
+
"""Convert a JSON Resume payload into a simple-resume YAML-shaped dict."""
|
|
251
|
+
basics = payload.get("basics")
|
|
252
|
+
basics = basics if isinstance(basics, Mapping) else {}
|
|
253
|
+
|
|
254
|
+
result: dict[str, Any] = {
|
|
255
|
+
"template": "resume_no_bars",
|
|
256
|
+
"config": {"template": "resume_no_bars"},
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
# Convert basics section
|
|
260
|
+
_convert_basics_simple(basics, result)
|
|
261
|
+
_convert_location(basics, result)
|
|
262
|
+
_convert_profiles(basics, result)
|
|
263
|
+
|
|
264
|
+
# Build body sections
|
|
265
|
+
body: dict[str, list[dict[str, Any]]] = {}
|
|
266
|
+
work_entries = _convert_work(payload)
|
|
267
|
+
if work_entries:
|
|
268
|
+
body["Experience"] = work_entries
|
|
269
|
+
education_entries = _convert_education(payload)
|
|
270
|
+
if education_entries:
|
|
271
|
+
body["Education"] = education_entries
|
|
272
|
+
project_entries = _convert_projects(payload)
|
|
273
|
+
if project_entries:
|
|
274
|
+
body["Projects"] = project_entries
|
|
275
|
+
if body:
|
|
276
|
+
result["body"] = body
|
|
277
|
+
|
|
278
|
+
# Convert skills
|
|
279
|
+
_convert_skills(payload, result)
|
|
280
|
+
|
|
281
|
+
return result
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
__all__ = ["json_resume_to_simple_resume", "looks_like_json_resume"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Core LaTeX functionality (pure, deterministic, no side effects).
|
|
2
|
+
|
|
3
|
+
This package contains pure business logic for LaTeX document generation.
|
|
4
|
+
All functions are deterministic and have no side effects (no file I/O,
|
|
5
|
+
no network access, no randomness).
|
|
6
|
+
|
|
7
|
+
The shell layer (simple_resume.shell.render.latex) handles all I/O operations
|
|
8
|
+
including template loading, file system access, and LaTeX compilation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from simple_resume.core.latex.context import build_latex_context_pure
|
|
12
|
+
from simple_resume.core.latex.conversion import (
|
|
13
|
+
collect_blocks,
|
|
14
|
+
convert_inline,
|
|
15
|
+
normalize_iterable,
|
|
16
|
+
)
|
|
17
|
+
from simple_resume.core.latex.escaping import escape_latex, escape_url
|
|
18
|
+
from simple_resume.core.latex.fonts import fontawesome_support_block
|
|
19
|
+
from simple_resume.core.latex.formatting import format_date, linkify
|
|
20
|
+
from simple_resume.core.latex.sections import (
|
|
21
|
+
build_contact_lines,
|
|
22
|
+
prepare_sections,
|
|
23
|
+
prepare_skill_sections,
|
|
24
|
+
)
|
|
25
|
+
from simple_resume.core.latex.types import (
|
|
26
|
+
Block,
|
|
27
|
+
LatexEntry,
|
|
28
|
+
LatexRenderResult,
|
|
29
|
+
LatexSection,
|
|
30
|
+
ListBlock,
|
|
31
|
+
ParagraphBlock,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
# Types
|
|
36
|
+
"Block",
|
|
37
|
+
"LatexEntry",
|
|
38
|
+
"LatexRenderResult",
|
|
39
|
+
"LatexSection",
|
|
40
|
+
"ListBlock",
|
|
41
|
+
"ParagraphBlock",
|
|
42
|
+
# Escaping
|
|
43
|
+
"escape_latex",
|
|
44
|
+
"escape_url",
|
|
45
|
+
# Conversion
|
|
46
|
+
"collect_blocks",
|
|
47
|
+
"convert_inline",
|
|
48
|
+
"normalize_iterable",
|
|
49
|
+
# Formatting
|
|
50
|
+
"format_date",
|
|
51
|
+
"linkify",
|
|
52
|
+
# Sections
|
|
53
|
+
"build_contact_lines",
|
|
54
|
+
"prepare_sections",
|
|
55
|
+
"prepare_skill_sections",
|
|
56
|
+
# Context
|
|
57
|
+
"build_latex_context_pure",
|
|
58
|
+
# Fonts
|
|
59
|
+
"fontawesome_support_block",
|
|
60
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""LaTeX context building functions (pure version without I/O)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from simple_resume.core.latex.conversion import collect_blocks, convert_inline
|
|
8
|
+
from simple_resume.core.latex.sections import (
|
|
9
|
+
build_contact_lines,
|
|
10
|
+
prepare_sections,
|
|
11
|
+
prepare_skill_sections,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_latex_context_pure(data: dict[str, Any]) -> dict[str, Any]:
|
|
16
|
+
"""Prepare the LaTeX template context from raw resume data (pure version).
|
|
17
|
+
|
|
18
|
+
This is the pure, core version of context building that does NOT perform
|
|
19
|
+
any file system operations. The fontawesome_block is set to None and must
|
|
20
|
+
be added by the shell layer which has access to the file system.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
data: Raw resume data dictionary.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Dictionary of context variables for LaTeX template rendering.
|
|
27
|
+
Note: fontawesome_block will be None and must be set by shell layer.
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
>>> data = {"full_name": "John Doe", "job_title": "Engineer"}
|
|
31
|
+
>>> context = build_latex_context_pure(data)
|
|
32
|
+
>>> context["full_name"]
|
|
33
|
+
'John Doe'
|
|
34
|
+
>>> context["headline"]
|
|
35
|
+
'Engineer'
|
|
36
|
+
|
|
37
|
+
"""
|
|
38
|
+
full_name = convert_inline(str(data.get("full_name", "")))
|
|
39
|
+
headline = data.get("job_title")
|
|
40
|
+
rendered_headline = convert_inline(str(headline)) if headline else None
|
|
41
|
+
summary_blocks = collect_blocks(data.get("description"))
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
"full_name": full_name,
|
|
45
|
+
"headline": rendered_headline,
|
|
46
|
+
"contact_lines": build_contact_lines(data),
|
|
47
|
+
"summary_blocks": summary_blocks,
|
|
48
|
+
"skill_sections": prepare_skill_sections(data),
|
|
49
|
+
"sections": prepare_sections(data),
|
|
50
|
+
"fontawesome_block": None, # Must be set by shell layer
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"build_latex_context_pure",
|
|
56
|
+
]
|