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:
Ian Holloway
2025-10-23 14:20:56 -04:00
committed by GitHub
parent 24f221827a
commit bebbf8d04c
7 changed files with 291 additions and 7 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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]

View File

@@ -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())

View File

@@ -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

View File

@@ -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."
}

View File

@@ -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"]