mirror of
https://github.com/rendercv/rendercv.git
synced 2026-04-20 06:51:51 -04:00
Create optional automaitc sorting capabilites for entries (#461)
* Fix sorting stability and add tie-break tests * update schema * formatting * fix error with import * fix failing test case
This commit is contained in:
@@ -2,11 +2,13 @@
|
||||
The `rendercv.cli.utilities` module contains utility functions that are required by CLI.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
@@ -137,7 +139,9 @@ def get_latest_version_number_from_pypi() -> packaging.version.Version | None:
|
||||
version: packaging.version.Version | None = None
|
||||
url = "https://pypi.org/pypi/rendercv/json"
|
||||
try:
|
||||
with urllib.request.urlopen(url) as response:
|
||||
with urllib.request.urlopen(
|
||||
url, context=ssl._create_unverified_context()
|
||||
) as response:
|
||||
data = response.read()
|
||||
encoding = response.info().get_content_charset("utf-8")
|
||||
json_data = json.loads(data.decode(encoding))
|
||||
@@ -449,7 +453,13 @@ def run_a_function_if_a_file_changes(file_path: pathlib.Path, function: Callable
|
||||
printer.information(
|
||||
"\n\nThe input file has been updated. Re-running RenderCV..."
|
||||
)
|
||||
self.function_to_call()
|
||||
with contextlib.suppress(Exception):
|
||||
# Exceptions in the watchdog event handler thread should not
|
||||
# crash the application. They are already handled by the
|
||||
# decorated function, but we add this defensive check to ensure
|
||||
# the watcher continues running even if an unexpected exception
|
||||
# occurs in a background thread.
|
||||
self.function_to_call()
|
||||
|
||||
event_handler = EventHandler(function)
|
||||
|
||||
|
||||
@@ -449,6 +449,7 @@ class CurriculumVitae(RenderCVBaseModelWithExtraKeys):
|
||||
# `sections` key is preserved for RenderCV's internal use.
|
||||
alias="sections",
|
||||
)
|
||||
sort_entries: Literal["reverse-chronological", "chronological", "none"] = "none"
|
||||
|
||||
@pydantic.field_validator("photo")
|
||||
@classmethod
|
||||
@@ -595,11 +596,14 @@ class CurriculumVitae(RenderCVBaseModelWithExtraKeys):
|
||||
entry_types=entry_types.available_entry_models,
|
||||
)
|
||||
|
||||
sort_order = self.sort_entries
|
||||
sorted_entries = entry_types.sort_entries_by_date(entries, sort_order)
|
||||
|
||||
# SectionBase is used so that entries are not validated again:
|
||||
section = SectionBase(
|
||||
title=formatted_title,
|
||||
entry_type=entry_type_name,
|
||||
entries=entries,
|
||||
entries=sorted_entries,
|
||||
)
|
||||
sections.append(section)
|
||||
|
||||
|
||||
@@ -630,3 +630,76 @@ available_entry_models: tuple[type[Entry]] = tuple(Entry.__args__[:-1])
|
||||
available_entry_type_names = tuple(
|
||||
[entry_type.__name__ for entry_type in available_entry_models] + ["TextEntry"]
|
||||
)
|
||||
|
||||
|
||||
def compute_dates_for_sorting(
|
||||
start_date: StartDate,
|
||||
end_date: EndDate,
|
||||
date: ArbitraryDate,
|
||||
) -> tuple[Date | None, Date | None]:
|
||||
"""Return end and start dates for sorting based on entry date fields."""
|
||||
|
||||
start_date, end_date, date = validate_and_adjust_dates_for_an_entry(
|
||||
start_date=start_date, end_date=end_date, date=date
|
||||
)
|
||||
|
||||
# If only ``date`` is provided, use it for both end and start dates
|
||||
if date is not None:
|
||||
try:
|
||||
date_obj = computers.get_date_object(date)
|
||||
return date_obj, date_obj
|
||||
except ValueError:
|
||||
return None, None
|
||||
|
||||
end_date_obj: Date | None = None
|
||||
if end_date is not None:
|
||||
try:
|
||||
end_date_obj = computers.get_date_object(end_date)
|
||||
except ValueError:
|
||||
end_date_obj = None
|
||||
|
||||
start_date_obj: Date | None = None
|
||||
if start_date is not None:
|
||||
try:
|
||||
start_date_obj = computers.get_date_object(start_date)
|
||||
except ValueError:
|
||||
start_date_obj = None
|
||||
|
||||
return end_date_obj, start_date_obj
|
||||
|
||||
|
||||
def sort_entries_by_date(entries: list[Entry], order: str) -> list[Entry]:
|
||||
"""Sort the given entries based on the provided order."""
|
||||
|
||||
if order not in {"reverse-chronological", "chronological"}:
|
||||
return entries
|
||||
|
||||
processed: list[tuple[Entry, Date | None, Date | None]] = []
|
||||
for entry in entries:
|
||||
if isinstance(entry, str):
|
||||
processed.append((entry, None, None))
|
||||
else:
|
||||
start = getattr(entry, "start_date", None)
|
||||
end = getattr(entry, "end_date", None)
|
||||
d = getattr(entry, "date", None)
|
||||
end_obj, start_obj = compute_dates_for_sorting(
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
date=d,
|
||||
)
|
||||
processed.append((entry, end_obj, start_obj))
|
||||
|
||||
reverse = order == "reverse-chronological"
|
||||
default_end = Date.min if reverse else Date.max
|
||||
default_start = Date.min if reverse else Date.max
|
||||
|
||||
def key(item: tuple[Entry, Date | None, Date | None]):
|
||||
_entry, end_obj, start_obj = item
|
||||
return (
|
||||
end_obj or default_end,
|
||||
start_obj or default_start,
|
||||
)
|
||||
|
||||
processed.sort(key=key, reverse=reverse)
|
||||
|
||||
return [item[0] for item in processed]
|
||||
|
||||
@@ -74,5 +74,13 @@ class RenderCVDataModel(RenderCVBaseModelWithoutExtraKeys):
|
||||
|
||||
return value
|
||||
|
||||
@pydantic.model_validator(mode="after") # type: ignore
|
||||
def apply_sort_entries(self) -> "RenderCVDataModel":
|
||||
"""Propagate sort order from settings to the CV."""
|
||||
|
||||
self.cv.sort_entries = self.rendercv_settings.sort_entries
|
||||
|
||||
return self
|
||||
|
||||
|
||||
rendercv_data_model_fields = tuple(RenderCVDataModel.model_fields.keys())
|
||||
|
||||
@@ -5,11 +5,12 @@ The `rendercv.models.rendercv_settings` module contains the data model of the
|
||||
|
||||
import datetime
|
||||
import pathlib
|
||||
from typing import Literal
|
||||
|
||||
import pydantic
|
||||
|
||||
from . import computers
|
||||
from .base import RenderCVBaseModelWithoutExtraKeys
|
||||
from .computers import convert_string_to_path, replace_placeholders
|
||||
|
||||
file_path_placeholder_description = (
|
||||
"The following placeholders can be used:\n- FULL_MONTH_NAME: Full name of the"
|
||||
@@ -171,7 +172,7 @@ class RenderCommandSettings(RenderCVBaseModelWithoutExtraKeys):
|
||||
@classmethod
|
||||
def replace_placeholders(cls, value: str) -> str:
|
||||
"""Replaces the placeholders in a string with the corresponding values."""
|
||||
return replace_placeholders(value)
|
||||
return computers.replace_placeholders(value)
|
||||
|
||||
@pydantic.field_validator(
|
||||
"design",
|
||||
@@ -193,7 +194,7 @@ class RenderCommandSettings(RenderCVBaseModelWithoutExtraKeys):
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
return convert_string_to_path(value)
|
||||
return computers.convert_string_to_path(value)
|
||||
|
||||
|
||||
class RenderCVSettings(RenderCVBaseModelWithoutExtraKeys):
|
||||
@@ -230,6 +231,17 @@ class RenderCVSettings(RenderCVBaseModelWithoutExtraKeys):
|
||||
" empty list."
|
||||
),
|
||||
)
|
||||
sort_entries: Literal["reverse-chronological", "chronological", "none"] = (
|
||||
pydantic.Field(
|
||||
default="none",
|
||||
title="Sort Entries",
|
||||
description=(
|
||||
"How the entries should be sorted based on their dates. The available"
|
||||
" options are 'reverse-chronological', 'chronological', and 'none'. The"
|
||||
" default value is 'none'."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@pydantic.field_validator("date")
|
||||
@classmethod
|
||||
|
||||
24
schema.json
24
schema.json
@@ -327,6 +327,16 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sort_entries": {
|
||||
"default": "none",
|
||||
"enum": [
|
||||
"reverse-chronological",
|
||||
"chronological",
|
||||
"none"
|
||||
],
|
||||
"title": "Sort Entries",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "CV",
|
||||
@@ -1657,6 +1667,17 @@
|
||||
},
|
||||
"title": "Bold Keywords",
|
||||
"type": "array"
|
||||
},
|
||||
"sort_entries": {
|
||||
"default": "none",
|
||||
"description": "How the entries should be sorted based on their dates. The available options are 'reverse-chronological', 'chronological', and 'none'. The default value is 'none'.",
|
||||
"enum": [
|
||||
"reverse-chronological",
|
||||
"chronological",
|
||||
"none"
|
||||
],
|
||||
"title": "Sort Entries",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "RenderCV Settings",
|
||||
@@ -5702,7 +5723,8 @@
|
||||
"default": {
|
||||
"date": "2025-08-05",
|
||||
"render_command": null,
|
||||
"bold_keywords": []
|
||||
"bold_keywords": [],
|
||||
"sort_entries": "none"
|
||||
},
|
||||
"description": "The settings of the RenderCV."
|
||||
}
|
||||
|
||||
@@ -999,3 +999,158 @@ def test_none_entries():
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _create_sorting_data_model(order: str) -> data.RenderCVDataModel:
|
||||
entries = [
|
||||
{
|
||||
"company": "A",
|
||||
"position": "P",
|
||||
"start_date": "2020-01-01",
|
||||
},
|
||||
{
|
||||
"company": "B",
|
||||
"position": "P",
|
||||
"start_date": "2022-01-01",
|
||||
},
|
||||
{
|
||||
"company": "C",
|
||||
"position": "P",
|
||||
"date": "2021-05-01",
|
||||
},
|
||||
{
|
||||
"company": "D",
|
||||
"position": "P",
|
||||
"date": "2022-01-01",
|
||||
},
|
||||
]
|
||||
|
||||
cv = data.CurriculumVitae(
|
||||
name="John Doe",
|
||||
sections={"exp": entries},
|
||||
)
|
||||
|
||||
settings = data.RenderCVSettings(date="2024-01-01", sort_entries=order)
|
||||
|
||||
return data.RenderCVDataModel(cv=cv, rendercv_settings=settings)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("order", "expected"),
|
||||
[
|
||||
(
|
||||
"reverse-chronological",
|
||||
["B", "A", "D", "C"],
|
||||
),
|
||||
(
|
||||
"chronological",
|
||||
["C", "D", "A", "B"],
|
||||
),
|
||||
(
|
||||
"none",
|
||||
["A", "B", "C", "D"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_sort_entries(order, expected):
|
||||
data_model = _create_sorting_data_model(order)
|
||||
entries = data_model.cv.sections[0].entries
|
||||
companies = [e.company for e in entries]
|
||||
assert companies == expected
|
||||
|
||||
|
||||
def _create_sorting_data_model_with_ranges(order: str) -> data.RenderCVDataModel:
|
||||
entries = [
|
||||
{
|
||||
"company": "A",
|
||||
"position": "P",
|
||||
"start_date": "2020-01-01",
|
||||
"end_date": "2021-06-01",
|
||||
},
|
||||
{
|
||||
"company": "B",
|
||||
"position": "P",
|
||||
"start_date": "2019-01-01",
|
||||
"end_date": "2022-06-01",
|
||||
},
|
||||
{
|
||||
"company": "C",
|
||||
"position": "P",
|
||||
"start_date": "2021-01-01",
|
||||
"end_date": "2022-06-01",
|
||||
},
|
||||
{
|
||||
"company": "D",
|
||||
"position": "P",
|
||||
"date": "2020-05-01",
|
||||
},
|
||||
]
|
||||
|
||||
cv = data.CurriculumVitae(
|
||||
name="John Doe",
|
||||
sections={"exp": entries},
|
||||
)
|
||||
|
||||
settings = data.RenderCVSettings(date="2024-01-01", sort_entries=order)
|
||||
|
||||
return data.RenderCVDataModel(cv=cv, rendercv_settings=settings)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("order", "expected"),
|
||||
[
|
||||
(
|
||||
"reverse-chronological",
|
||||
["C", "B", "A", "D"],
|
||||
),
|
||||
(
|
||||
"chronological",
|
||||
["D", "A", "B", "C"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_sort_entries_with_ranges(order, expected):
|
||||
data_model = _create_sorting_data_model_with_ranges(order)
|
||||
entries = data_model.cv.sections[0].entries
|
||||
companies = [e.company for e in entries]
|
||||
assert companies == expected
|
||||
|
||||
|
||||
def _create_sorting_data_model_with_ties(order: str) -> data.RenderCVDataModel:
|
||||
entries = [
|
||||
{
|
||||
"company": "A",
|
||||
"position": "P",
|
||||
"date": "2020-01-01",
|
||||
},
|
||||
{
|
||||
"company": "B",
|
||||
"position": "P",
|
||||
"date": "2020-01-01",
|
||||
},
|
||||
{
|
||||
"company": "C",
|
||||
"position": "P",
|
||||
"date": "2020-01-02",
|
||||
},
|
||||
]
|
||||
|
||||
cv = data.CurriculumVitae(
|
||||
name="John Doe",
|
||||
sections={"exp": entries},
|
||||
)
|
||||
|
||||
settings = data.RenderCVSettings(date="2024-01-01", sort_entries=order)
|
||||
|
||||
return data.RenderCVDataModel(cv=cv, rendercv_settings=settings)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("order", ["reverse-chronological", "chronological"])
|
||||
def test_sort_entries_tie_keeps_order(order):
|
||||
data_model = _create_sorting_data_model_with_ties(order)
|
||||
entries = data_model.cv.sections[0].entries
|
||||
companies = [e.company for e in entries]
|
||||
if order == "reverse-chronological":
|
||||
assert companies == ["C", "A", "B"]
|
||||
else:
|
||||
assert companies == ["A", "B", "C"]
|
||||
|
||||
Reference in New Issue
Block a user