diff --git a/rendercv/__main__.py b/rendercv/__main__.py index 6e279cd..f659494 100644 --- a/rendercv/__main__.py +++ b/rendercv/__main__.py @@ -39,22 +39,22 @@ def user_friendly_errors(func: Callable) -> Callable: except ValidationError as e: # It is a Pydantic error error_messages = [] - error_messages.append("There are some problems with your input 🧐") + error_messages.append("There are some problems with your input.") # Translate Pydantic's error messages to make them more user-friendly custom_error_messages_by_type = { - "url_scheme": "This is not a valid URL 😿", - "string_type": "This is not a valid string 🤭", - "missing": "This field is required, but it is missing 😆", - "literal_error": "Only the following values are allowed: {expected} 😒", + "url_scheme": "This is not a valid URL.", + "string_type": "This is not a valid string.", + "missing": "This field is required, but it is missing.", + "literal_error": "Only the following values are allowed: {expected}.", } custom_error_messages_by_msg = { "value is not a valid phone number": ( - "This is not a valid phone number 👺" + "This is not a valid phone number." ), "String should match pattern '\\d+\\.?\\d* *(cm|in|pt|mm|ex|em)'": ( "This is not a valid length! Use a number followed by a unit " - "of length (cm, in, pt, mm, ex, em) 👺" + "of length (cm, in, pt, mm, ex, em)." ), } new_errors: list[ErrorDetails] = [] @@ -118,7 +118,7 @@ def user_friendly_errors(func: Callable) -> Callable: # It is a YAML parser error new_args = list(e.args) new_args = [str(arg).strip() for arg in new_args] - new_args[0] = "There is a problem with your input file 🤦‍" + new_args[0] = "There is a problem with your input file.‍" error_message = "\n\n ".join(new_args) logger.error(error_message) diff --git a/rendercv/data_model.py b/rendercv/data_model.py index 5073a4c..a956dca 100644 --- a/rendercv/data_model.py +++ b/rendercv/data_model.py @@ -177,14 +177,14 @@ def parse_date_string(date_string: str) -> Date | int: else: raise ValueError( f'The date string "{date_string}" is not in YYYY-MM-DD, YYYY-MM, or YYYY' - " format 🥶" + " format." ) if isinstance(date, Date): # Then it means the date is a Date object, so check if it is a past date: if date > Date.today(): raise ValueError( - f'The date "{date_string}" is in the future. Please check the dates 🤯' + f'The date "{date_string}" is in the future. Please check the dates.' ) return date @@ -218,19 +218,16 @@ def compute_time_span_string(start_date: Date | int, end_date: Date | int) -> st # calculate the number of days between start_date and end_date: if isinstance(start_date, Date) and isinstance(end_date, Date): timespan_in_days = (end_date - start_date).days - elif isinstance(start_date, int) and isinstance(end_date, int): - timespan_in_days = (end_date - start_date) * 365 + elif isinstance(start_date, Date) and isinstance(end_date, int): + timespan_in_days = (Date(end_date, 1, 1) - start_date).days elif isinstance(start_date, int) and isinstance(end_date, Date): timespan_in_days = (end_date - Date(start_date, 1, 1)).days - else: - raise TypeError( - f"start_date's type is {type(start_date)} and end_date's type is" - f" {type(end_date)}. This is not supported." - ) + elif isinstance(start_date, int) and isinstance(end_date, int): + timespan_in_days = (end_date - start_date) * 365 if timespan_in_days < 0: raise ValueError( - '"start_date" can not be after "end_date". Please check the dates 👻' + '"start_date" can not be after "end_date". Please check the dates.' ) # calculate the number of years between start_date and end_date: @@ -382,13 +379,6 @@ LaTeXDimension = Annotated[ pattern=r"\d+\.?\d* *(cm|in|pt|mm|ex|em)", ), ] -LaTeXString = Annotated[str, AfterValidator(escape_latex_characters)] -SpellCheckedString = Annotated[LaTeXString, AfterValidator(check_spelling)] -PastDate = Annotated[ - str, - Field(pattern=r"\d{4}-?(\d{2})?-?(\d{2})?"), - AfterValidator(parse_date_string), -] class ClassicThemePageMargins(BaseModel): @@ -592,15 +582,15 @@ class Design(BaseModel): if model.theme == "classic": model.options = ClassicThemeOptions() else: - raise RuntimeError("Unknown theme 👿") + raise RuntimeError(f'The theme "{model.theme}" does not exist.') else: if model.theme == "classic": if not isinstance(model.options, ClassicThemeOptions): raise ValueError( - "Theme is classic but options is not classic theme options 🥱" + "Theme is classic but options is not classic theme options." ) else: - raise RuntimeError("Unknown theme 👿") + raise RuntimeError(f'The theme "{model.theme}"" does not exist.') return model @@ -613,7 +603,7 @@ class Design(BaseModel): fonts_directory = str(files("rendercv").joinpath("templates", "fonts")) if font not in os.listdir(fonts_directory): raise ValueError( - f'The font "{font}" is not found in the "fonts" directory 🥴' + f'The font "{font}" is not found in the "fonts" directory.' ) else: font_directory = os.path.join(fonts_directory, font) @@ -625,7 +615,7 @@ class Design(BaseModel): ] for file in required_files: if file not in os.listdir(font_directory): - raise ValueError(f"{file} is not found in the {font} directory 😡") + raise ValueError(f"{file} is not found in the {font} directory.") return font @@ -636,7 +626,7 @@ class Design(BaseModel): template_directory = str(files("rendercv").joinpath("templates", theme)) if f"{theme}.tex.j2" not in os.listdir(template_directory): raise ValueError( - f'The theme "{theme}" is not found in the "templates" directory 🤥' + f'The theme "{theme}" is not found in the "templates" directory.' ) return theme @@ -650,6 +640,14 @@ class Design(BaseModel): # CONTENT MODELS ======================================================================= # ====================================================================================== +LaTeXString = Annotated[str, AfterValidator(escape_latex_characters)] +SpellCheckedString = Annotated[LaTeXString, AfterValidator(check_spelling)] +PastDate = Annotated[ + str, + Field(pattern=r"\d{4}-?(\d{2})?-?(\d{2})?"), + AfterValidator(parse_date_string), +] + class Event(BaseModel): """This class is the parent class for classes like `#!python EducationEntry`, @@ -768,24 +766,31 @@ class Event(BaseModel): if model.start_date is not None and model.end_date is not None: if model.end_date == "present": end_date = Date.today() - else: + elif isinstance(model.end_date, int): + # Then it means user only provided the year, so convert it to a Date + # object with the first day of the year (just for the date comparison) + end_date = Date(model.end_date, 1, 1) + elif isinstance(model.end_date, Date): + # Then it means user provided either YYYY-MM-DD or YYYY-MM end_date = model.end_date + else: + raise RuntimeError("end_date is neither an integer nor a Date object.") if isinstance(model.start_date, int): # Then it means user only provided the year, so convert it to a Date - # object with the first day of the year + # object with the first day of the year (just for the date comparison) start_date = Date(model.start_date, 1, 1) elif isinstance(model.start_date, Date): # Then it means user provided either YYYY-MM-DD or YYYY-MM start_date = model.start_date else: raise RuntimeError( - "start_date is neither an integer nor a Date object 🤯" + "start_date is neither an integer nor a Date object." ) if start_date > end_date: raise ValueError( - '"start_date" can not be after "end_date". Please check the dates 👻' + '"start_date" can not be after "end_date". Please check the dates.' ) return model @@ -804,7 +809,7 @@ class Event(BaseModel): elif isinstance(self.date, Date): date_and_location_strings.append(format_date(self.date)) else: - raise RuntimeError("Date is neither a string nor a Date object 😵") + raise RuntimeError("Date is neither a string nor a Date object.") elif self.start_date is not None and self.end_date is not None: start_date = format_date(self.start_date) @@ -1020,7 +1025,7 @@ class PublicationEntry(Event): urllib.request.urlopen(doi_url) except urllib.request.HTTPError as err: if err.code == 404: - raise ValueError(f"{doi} cannot be found in the DOI System 🤖") + raise ValueError(f"{doi} cannot be found in the DOI System.") return doi @@ -1122,7 +1127,7 @@ class Connection(BaseModel): elif self.name == "location": url = None else: - raise RuntimeError(f'"{self.name}" is not a valid connection 🤡') + raise RuntimeError(f'"{self.name}" is not a valid connection.') return url @@ -1375,7 +1380,7 @@ class CurriculumVitae(BaseModel): duplicates = {val for val in section_names if (val in seen or seen.add(val))} if len(duplicates) > 0: raise ValueError( - "The section names should be unique 🧐. The following section names are" + "The section names should be unique. The following section names are" f" duplicated: {duplicates}" ) @@ -1515,7 +1520,7 @@ class CurriculumVitae(BaseModel): raise ValueError( f'"{section_name}" is not a valid section name. Please create a' " custom section with this name or delete it from the section" - " order 😷" + " order." ) object_map = { @@ -1592,9 +1597,9 @@ class RenderCVDataModel(BaseModel): not_used_section_titles = ", ".join(not_used_section_titles) raise ValueError( f'The section "{title}" that is specified in the' - ' "show_timespan_in" option is not found in the CV 😱 You' + ' "show_timespan_in" option is not found in the CV. You' " might have wanted to use one of these:" - f" {not_used_section_titles}" + f" {not_used_section_titles}." ) return model @@ -1614,7 +1619,7 @@ def read_input_file(file_path: str) -> RenderCVDataModel: # check if the file exists: if not os.path.exists(file_path): - raise FileNotFoundError(f"The file {file_path} doesn't exist 🙄") + raise FileNotFoundError(f"The file {file_path} doesn't exist.") # check the file extension: accepted_extensions = [".yaml", ".yml", ".json", ".json5"] diff --git a/tests/test_data_model.py b/tests/test_data_model.py index 74f66ae..b255bde 100644 --- a/tests/test_data_model.py +++ b/tests/test_data_model.py @@ -64,6 +64,28 @@ class TestDataModel(unittest.TestCase): with self.assertRaises(ValueError): data_model.compute_time_span_string(start_date, end_date) + # If users provide only year and month, or only year, the function should still + # work: + dates = { + ( + Date(year=2020, month=1, day=1), + 2021, + "start_date and YYYY end_date", + "1 year 1 month", + ), + ( + 2020, + Date(year=2021, month=1, day=1), + "YYYY start_date and end_date", + "1 year 1 month", + ), + (2020, 2021, "YYYY start_date and YYYY end_date", "1 year 1 month"), + } + for start_date, end_date, msg, expected_result in dates: + with self.subTest(msg=msg): + result = data_model.compute_time_span_string(start_date, end_date) + self.assertEqual(result, expected_result) + # invalid inputs: start_date = None end_date = Date(year=2023, month=3, day=2) @@ -167,47 +189,44 @@ class TestDataModel(unittest.TestCase): def test_data_event_check_dates(self): # Inputs with valid dates: - input = { - "start_date": "2020-01-01", - "end_date": "2021-01-01", - "date": None, + # All the combinations are tried. In valid dates: + # Start dates can be 4 different things: YYYY-MM-DD, YYYY-MM, YYYY. + # End dates can be 5 different things: YYYY-MM-DD, YYYY-MM, YYYY, or "present" or None. + start_dates = { + "2020-01-01": Date.fromisoformat("2020-01-01"), + "2020-01": Date.fromisoformat("2020-01-01"), + "2020": 2020, } - with self.subTest(msg="valid date with start_date and end_date"): - event = data_model.Event(**input) - self.assertEqual(event.start_date, Date.fromisoformat(input["start_date"])) - self.assertEqual(event.end_date, Date.fromisoformat(input["end_date"])) - self.assertEqual(event.date, None) - - input = { - "start_date": "2020-01-01", - "end_date": None, - "date": None, + end_dates = { + "2021-01-01": Date.fromisoformat("2021-01-01"), + "2021-01": Date.fromisoformat("2021-01-01"), + "2021": 2021, + "present": "present", + None: "present", } - with self.subTest(msg="valid date with start_date"): - event = data_model.Event(**input) - self.assertEqual( - event.start_date, - Date.fromisoformat(input["start_date"]), - msg="Start date is not correct.", - ) - self.assertEqual(event.end_date, "present", msg="End date is not correct.") - self.assertEqual(event.date, None, msg="Date is not correct.") - - input = { - "start_date": "2020-01-01", - "end_date": "present", - "date": None, - } - with self.subTest(msg="valid date with start_date and end_date=present"): - event = data_model.Event(**input) - self.assertEqual( - event.start_date, - Date.fromisoformat(input["start_date"]), - msg="Start date is not correct.", - ) - self.assertEqual(event.end_date, "present", msg="End date is not correct.") - self.assertEqual(event.date, None, msg="Date is not correct.") + combinations = [ + (start_date, end_date) + for start_date in start_dates + for end_date in end_dates + ] + for start_date, end_date in combinations: + input = { + "start_date": start_date, + "end_date": end_date, + "date": None, + } + with self.subTest(msg=f"valid date with {start_date} and {end_date}"): + event = data_model.Event(**input) + self.assertEqual( + event.start_date, + start_dates[start_date], + ) + self.assertEqual( + event.end_date, + end_dates[end_date], + ) + # Valid dates but edge cases: input = { "start_date": None, "end_date": None, @@ -295,7 +314,7 @@ class TestDataModel(unittest.TestCase): self.assertEqual(event.end_date, None, msg="End date is not correct.") self.assertEqual(event.date, None, msg="Date is not correct.") - # Inputs with invalid dates: + # Invalid dates: input = { "start_date": "2020-01-01", "end_date": "2019-01-01",