From bebbf8d04c4987d88f868c10faa7c2c4deee86da Mon Sep 17 00:00:00 2001 From: Ian Holloway Date: Thu, 23 Oct 2025 14:20:56 -0400 Subject: [PATCH] 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 --- rendercv/cli/utilities.py | 14 +- rendercv/data/models/curriculum_vitae.py | 6 +- rendercv/data/models/entry_types.py | 73 +++++++++ rendercv/data/models/rendercv_data_model.py | 8 + rendercv/data/models/rendercv_settings.py | 18 ++- schema.json | 24 ++- tests/test_data.py | 155 ++++++++++++++++++++ 7 files changed, 291 insertions(+), 7 deletions(-) diff --git a/rendercv/cli/utilities.py b/rendercv/cli/utilities.py index 908d6ac7..c4918076 100644 --- a/rendercv/cli/utilities.py +++ b/rendercv/cli/utilities.py @@ -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) diff --git a/rendercv/data/models/curriculum_vitae.py b/rendercv/data/models/curriculum_vitae.py index f1e68b8a..e5844e48 100644 --- a/rendercv/data/models/curriculum_vitae.py +++ b/rendercv/data/models/curriculum_vitae.py @@ -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) diff --git a/rendercv/data/models/entry_types.py b/rendercv/data/models/entry_types.py index 3c54a267..c3ea3108 100644 --- a/rendercv/data/models/entry_types.py +++ b/rendercv/data/models/entry_types.py @@ -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] diff --git a/rendercv/data/models/rendercv_data_model.py b/rendercv/data/models/rendercv_data_model.py index 94695bf4..75f56443 100644 --- a/rendercv/data/models/rendercv_data_model.py +++ b/rendercv/data/models/rendercv_data_model.py @@ -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()) diff --git a/rendercv/data/models/rendercv_settings.py b/rendercv/data/models/rendercv_settings.py index 79f551d4..89ed7c6c 100644 --- a/rendercv/data/models/rendercv_settings.py +++ b/rendercv/data/models/rendercv_settings.py @@ -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 diff --git a/schema.json b/schema.json index cbb5851e..830af77f 100644 --- a/schema.json +++ b/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." } diff --git a/tests/test_data.py b/tests/test_data.py index 7a192a88..af70eb20 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -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"]