diff --git a/.gitignore b/.gitignore index 68a42d4..c75e821 100755 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ _tests/files/output-results #/.idea/usage.statistics.xml already included below #/.idea/shelf already included below /.vscode +# For some reason PyCharm randomly decided to not use the uppercase Obsidian-Daylio-Parser.iml file anymore +# One day it's like "Hey, let's switch to obsidian-daylio-parser.iml, that's a much cooler config filename!" +# So I made a lowercase symlink and flipped PyCharm off +/.idea/obsidian-daylio-parser.iml /.idea/inspectionProfiles/Project_Default.xml /.idea/Obsidian-Daylio-Parser.bak.iml diff --git a/.idea/Obsidian-Daylio-Parser.iml b/.idea/Obsidian-Daylio-Parser.iml index 819803c..2711323 100644 --- a/.idea/Obsidian-Daylio-Parser.iml +++ b/.idea/Obsidian-Daylio-Parser.iml @@ -5,9 +5,8 @@ - - + diff --git a/.idea/misc.xml b/.idea/misc.xml index 3bd84fd..d0a9f2c 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,7 +3,7 @@ - + diff --git a/.idea/runConfigurations/Run_all_tests.xml b/.idea/runConfigurations/Run_all_tests.xml index 5939e31..173f598 100644 --- a/.idea/runConfigurations/Run_all_tests.xml +++ b/.idea/runConfigurations/Run_all_tests.xml @@ -1,16 +1,18 @@ - + + diff --git a/Dockerfile b/Dockerfile index 15803ee..6be159e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,12 +5,9 @@ LABEL org.label-schema.name="Obsidian Daylio Parser" LABEL org.label-schema.description="Convert .csv Daylio backup into markdown notes." LABEL org.label-schema.url="https://github.com/DeutscheGabanna/Obsidian-Daylio-Parser/" -COPY src /app/src/ -COPY _tests /app/_tests/ - WORKDIR /app +COPY tests . +RUN pip install daylio-obsidian-parser -RUN mkdir output - -ENTRYPOINT [ "python", "src/main.py" ] -CMD [ "_tests/sheet-1-valid-data.csv", "output"] \ No newline at end of file +ENTRYPOINT ["daylio_to_md"] +CMD ["--help"] \ No newline at end of file diff --git a/Dockerfile-test-suite b/Dockerfile-test-suite new file mode 100644 index 0000000..884d5d8 --- /dev/null +++ b/Dockerfile-test-suite @@ -0,0 +1,13 @@ +FROM python:3.13.0b2-slim-bookworm + +LABEL org.label-schema.schema-version="1.0" +LABEL org.label-schema.name="Obsidian Daylio Parser" +LABEL org.label-schema.description="Convert .csv Daylio backup into markdown notes." +LABEL org.label-schema.url="https://github.com/DeutscheGabanna/Obsidian-Daylio-Parser/" + +WORKDIR /app +COPY tests/ . +RUN pip install daylio-obsidian-parser + +ENTRYPOINT ["daylio_to_md"] +CMD ["--help"] \ No newline at end of file diff --git a/src/daylio_to_md/__main__.py b/src/daylio_to_md/__main__.py index b749f37..cab82c9 100644 --- a/src/daylio_to_md/__main__.py +++ b/src/daylio_to_md/__main__.py @@ -1,7 +1,8 @@ import sys +import logging from daylio_to_md.config import options -from daylio_to_md.librarian import Librarian +from daylio_to_md.librarian import Librarian, CannotAccessJournalError, EmptyJournalError def main(): @@ -17,4 +18,12 @@ def main(): if __name__ == '__main__': - main() + try: + main() + except CannotAccessJournalError as err: + # Invoking help() instead of writing it directly just helps to cut down on duplicate strings + logging.getLogger(__name__).critical(err.__doc__) + sys.exit(1) + except EmptyJournalError as err: + logging.getLogger(__name__).critical(err.__doc__) + sys.exit(2) diff --git a/src/daylio_to_md/config.py b/src/daylio_to_md/config.py index b7e4449..f4a308c 100755 --- a/src/daylio_to_md/config.py +++ b/src/daylio_to_md/config.py @@ -20,7 +20,10 @@ ├── suffix ├── colour └── header + """ +# https://www.doc.ic.ac.uk/~nuric/posts/coding/how-to-handle-configuration-in-python/ + import argparse import logging from typing import List, Any diff --git a/src/daylio_to_md/dated_entries_group.py b/src/daylio_to_md/dated_entries_group.py deleted file mode 100644 index ffd631e..0000000 --- a/src/daylio_to_md/dated_entries_group.py +++ /dev/null @@ -1,258 +0,0 @@ -""" -This file specialises in building the middleware between actual journal entries and the whole journal. -It creates and organises only those entries written on a particular date. This way they can be handled easier. - -Here's a quick breakdown of what is the specialisation of this file in the journaling process: -all notes -> _NOTES WRITTEN ON A PARTICULAR DATE_ -> a particular note -""" -from __future__ import annotations - -import io -import logging -import re -import typing - -from daylio_to_md import dated_entry -from daylio_to_md import utils, errors -from daylio_to_md.config import options -from daylio_to_md.dated_entry import DatedEntry -from daylio_to_md.entry.mood import Moodverse - - -class DatedEntryMissingError(utils.CustomException): - """The :class:`DatedEntry` does not exist.""" - - -class IncompleteDataRow(utils.CustomException): - """Passed a row of data from CSV file that does not have all required fields.""" - - -class InvalidDateError(utils.CustomException): - """String is not a valid date. Check :class:`Date` for details.""" - - -class TriedCreatingDuplicateDatedEntryError(utils.CustomException): - """Tried to create object of :class:`DatedEntry` that would be a duplicate of one that already exists.""" - - -class ErrorMsg(errors.ErrorMsgBase): - CSV_ROW_INCOMPLETE_DATA = "Passed .csv contains rows with invalid data. Tried to parse {} as date." - - -class Date: - """ - Day, month and year of a particular date. Validates the date string on instantiation. - """ - _instances = {} # Class variable to store instances based on date - - def __new__(cls, string: str): - # Check if an instance for the given date already exists - if string in cls._instances: - return cls._instances[string] - # If not, create a new instance - instance = super(Date, cls).__new__(cls) - cls._instances[string] = instance - return instance - - def __init__(self, string: str): - """ - :raises InvalidDateError: if :param:`string` is not a valid date (for example the month number > 12) - :param string: on which entries have been created (`YYYY-MM-DD`) - """ - # self.__logger = logging.getLogger(self.__class__.__name__) - - # does it have a valid format YYYY-MM-DD - valid_date_pattern = re.compile(r'^\d{4}-\d{1,2}-\d{1,2}$') - if not re.match(valid_date_pattern, string): - raise InvalidDateError - - # does it have valid ranges? (year ranges are debatable) - date_array = string.strip().split('-') - if not all(( - 1900 < int(date_array[0]) < 2100, - 0 < int(date_array[1]) < 13, - 0 < int(date_array[2][:2]) < 32)): - raise InvalidDateError - - self.__year = date_array[0] - self.__month = date_array[1] - self.__day = date_array[2] - - def __str__(self) -> str: - """ - :return: returns the valid date in the YYYY-MM-DD format. This is the superior format, end of discussion. - """ - return '-'.join([self.__year, self.__month, self.__day]) - - def __repr__(self): - return "{}(year={}, month={}, day={})".format(self.__class__.__name__, self.__year, self.__month, self.__day) - - def __eq__(self, other: 'Date') -> bool: - """Used only for comparing two :class:`Date` objects - itself and another one.""" - if isinstance(other, Date): - return all((other.year == self.year, - other.month == self.month, - other.day == self.day)) - return super().__eq__(other) - - @property - def year(self): - return self.__year - - @property - def month(self): - return self.__month - - @property - def day(self): - return self.__day - - -class DatedEntriesGroup(utils.Core): - """ - A particular date which groups entries written that day. - - Imagine it as a scribe, holding a stack of papers in his hand. - The master Librarian knows each one of the scribes, including this one. - However, the scribe knows only his papers. The papers contain all entries written that particular date. - - Truthy if it knows at least one :class:`DatedEntry` made on this :class:`Date`. - """ - _instances = {} - - def __new__(cls, date: str, current_mood_set: Moodverse = Moodverse()): - # Check if an instance for the given date already exists - if date in cls._instances: - return cls._instances[date] - # If not, create a new instance - instance = super(DatedEntriesGroup, cls).__new__(cls) - cls._instances[date] = instance - return instance - - def __init__(self, date, current_mood_set: Moodverse = Moodverse()): - """ - :raises InvalidDateError: if the date string is deemed invalid by :class:`Date` - :param date: The date for all child entries within. - :param current_mood_set: Use custom :class:`Moodverse` or default if not provided. - """ - self.__logger = logging.getLogger(self.__class__.__name__) - - # Try parsing the date and assigning it as your identification (uid) - try: - super().__init__(Date(date)) - # Date is no good? - except InvalidDateError as err: - msg = ErrorMsg.print(ErrorMsg.WRONG_VALUE, date, "YYYY-MM-DD") - self.__logger.warning(msg) - raise InvalidDateError(msg) from err - - # All good - initialise - self.__known_entries_for_this_date: dict[str, DatedEntry] = {} - self.__known_moods: Moodverse = current_mood_set - - def append_to_known(self, entry: DatedEntry) -> None: - self.__known_entries_for_this_date[str(entry.uid)] = entry - - def create_dated_entry_from_row(self, - line: dict[str, str]) -> dated_entry.DatedEntry: - """ - :func:`access_dated_entry` of :class:`DatedEntry` object with the specified parameters. - :raises TriedCreatingDuplicateDatedEntryError: if it would result in making a duplicate :class:`DatedEntry` - :raises IncompleteDataRow: if ``line`` does not have ``time mood`` keys at the very least, or either is empty - :raises ValueError: re-raises ValueError from :class:`DatedEntry` - :param line: a dictionary of strings. Required keys: mood, activities, note_title & note. - """ - # TODO: test case this - # Try accessing the minimum required keys - for key in ["time", "mood"]: - try: - line[key] - except KeyError as err: - raise IncompleteDataRow(key) from err - # is it empty then, maybe? - if not line[key]: - raise IncompleteDataRow(key) - - # Check if there's already an object with this time - # TODO: Daylio actually allows creating multiple entries and mark them as written at the same time - if line["time"] in self.__known_entries_for_this_date: - raise TriedCreatingDuplicateDatedEntryError - - # Instantiate the entry - try: - this_entry = dated_entry.DatedEntry( - line["time"], - line["mood"], - activities=line["activities"], - title=line["note_title"], - note=line["note"], - override_mood_set=self.__known_moods - ) - except ValueError as err: - raise ValueError from err - - self.append_to_known(this_entry) - return this_entry - - def access_dated_entry(self, time: str) -> DatedEntry: - """ - Retrieve an already existing DatedEntry object. - :param time: any string, but if it's not a valid HH:MM format then I guarantee it won't be found either way - :raises DatedEntryMissingError: if the entry specified in ``time`` does not exist - :returns: :class:`DatedEntry` - """ - try: - ref = self.__known_entries_for_this_date[time] - except KeyError as err: - msg = ErrorMsg.print(ErrorMsg.OBJECT_NOT_FOUND, time) - self.__logger.warning(msg) - raise DatedEntryMissingError(msg) from err - self.__logger.debug(ErrorMsg.print(ErrorMsg.OBJECT_FOUND, time)) - return ref - - def output(self, stream: io.IOBase | typing.IO) -> int: - """ - Write entry contents of all :class:`DatedEntry` known directly into the provided buffer stream. - It is the responsibility of the caller to handle the stream afterward. - :raises utils.StreamError: if the passed stream does not support writing to it. - :raises OSError: likely due to lack of space in memory or filesystem, depending on the stream - :param stream: Since it expects the base :class:`io.IOBase` class, it accepts both file and file-like streams. - :returns: how many characters were successfully written into the stream. - """ - if not stream.writable(): - raise utils.StreamError - - chars_written = 0 - # THE BEGINNING OF THE FILE - # when appending file tags at the beginning of the file, discard any duplicates or falsy strings - # sorted() is used to have a deterministic order, set() was random, so I couldn't properly test the output - valid_tags = sorted(set(val for val in options.tags if val)) - if valid_tags: - # why '\n' instead of os.linesep? - # > Do not use os.linesep as a line terminator when writing files opened in text mode (the default); - # > use a single '\n' instead, on all platforms. - # https://docs.python.org/3.10/library/os.html#os.linesep - chars_written += stream.write("---" + "\n") - chars_written += stream.write("tags: " + ",".join(valid_tags) + "\n") - chars_written += stream.write("---" + "\n"*2) - - # THE ACTUAL ENTRY CONTENTS - # Each DatedEntry object now appends its contents into the stream - for entry in self.__known_entries_for_this_date.values(): - # write returns the number of characters successfully written - # https://docs.python.org/3/library/io.html#io.TextIOBase.write - if entry.output(stream) > 0: - chars_written += stream.write("\n"*2) - - return chars_written - - @property - def known_entries_from_this_day(self): - return self.__known_entries_for_this_date - - @property - def date(self): - """ - :return: String in the format of YYYY-MM-DD that identifies this specific object of :class:`DatedEntryGroup`. - """ - return self.uid diff --git a/src/daylio_to_md/dated_entry.py b/src/daylio_to_md/dated_entry.py deleted file mode 100644 index 7c61a2b..0000000 --- a/src/daylio_to_md/dated_entry.py +++ /dev/null @@ -1,283 +0,0 @@ -""" -dated_entry focuses on building the individual entries, made at a particular moment, as objects. -It is the most atomic level of the journaling process. - -Here's a quick breakdown of what is the specialisation of this file in the journaling process: -all notes -> notes written on a particular date -> _A PARTICULAR NOTE_ -""" -from __future__ import annotations - -import io -import logging -import re -import typing - -from daylio_to_md import utils, errors -from daylio_to_md.config import options -from daylio_to_md.entry.mood import Moodverse - -# Adding DatedEntry-specific options in global_settings -dated_entry_settings = options.arg_console.add_argument_group( - "Dated Entries", - "Handles how entries should be formatted" -) -dated_entry_settings.add_argument( - "--tags", - help="Tags in the YAML of each note.", - nargs='*', # this allows, for example, "--tags one two three" --> ["one", "two", "three"] - default="daily", - dest="TAGS" -) -dated_entry_settings.add_argument( - "--prefix", # YYYY-MM-DD.md so remember about a separating char - default='', - help="Prepends a given string to each entry's header." -) -dated_entry_settings.add_argument( - "--suffix", # YYYY-MM-DD.md so remember about a separating char - default='', - help="Appends a given string at the end of each entry's header." -) -dated_entry_settings.add_argument( - "--tag_activities", "-a", - action="store_true", # default=True - help="Tries to convert activities into valid tags.", - dest="ACTIVITIES_AS_TAGS" -) -dated_entry_settings.add_argument( - "-colour", "--color", - action="store_true", # default=True - help="Prepends a colour emoji to each entry depending on mood.", - dest="colour" -) -dated_entry_settings.add_argument( - "--header", - type=int, - default=2, - help="Headings level for each entry.", - dest="HEADER_LEVEL" -) -dated_entry_settings.add_argument( - "--csv-delimiter", - default="|", - help="Set delimiter for activities in Daylio .CSV, e.g: football | chess" -) - - -class IsNotTimeError(utils.CustomException): - msg = "Expected HH:MM (+ optionally AM/PM suffix) but got {} instead." - - def __init__(self, tried_time: str): - super().__init__(type(self).msg.format(tried_time)) - - -class ErrorMsg(errors.ErrorMsgBase): - INVALID_MOOD = "Mood {} is missing from a list of known moods. Not critical, but colouring won't work on the entry." - WRONG_TIME = "Received {}, expected valid time. Cannot create this entry without a valid time." - WRONG_ACTIVITIES = "Expected a non-empty list of activities. In that case just omit this argument in function call." - WRONG_TITLE = "Expected a non-empty title. Omit this argument in function call rather than pass a falsy string." - WRONG_NOTE = "Expected a non-empty note. Omit this argument in function call rather than pass a falsy string." - - -class Time: - """ - Hour and minutes of a particular moment in time. Validates the time string on instantiation. - str(instance) returns the valid time in the ``HH:MM`` format. - :raises IsNotTimeError: if string is not a valid time in ``HH:MM`` format (either AM/PM or 24h) - """ - - def __init__(self, string: str): - """ - Upon instantiation checks if the time is valid. - Used in :class:`DatedEntry` to create an instance of this class. - :raises IsNotTime: if string is not a valid time in ``HH:MM`` format with optional AM/PM appended - :param string: time in ``HH:MM`` format - can have AM/PM appended - """ - self.__logger = logging.getLogger(self.__class__.__name__) - - # OK - if Time.is_format_valid(string.strip()) and Time.is_range_valid(string.strip()): - time_array = string.strip().split(':') - self.__hour = time_array[0] - self.__minutes = time_array[1] - - # NOT OK - else: - self.__logger.warning(IsNotTimeError.msg.format(string)) - raise IsNotTimeError(string) - - def __str__(self) -> str: - """ - :return: Outputs its hour and minutes attributes as a string in valid time format - HH:MM. - """ - return ':'.join([self.__hour, self.__minutes]) - - # thematically fits in Time nicely, but does not operate on cls or self - @staticmethod - def is_format_valid(string: str) -> bool: - """ - Is the time format of :param:`str` valid? - :param string: time to check - :return: ``True`` if :param:`str` follows the ``HH:MM`` format, with optional AM/PM appended, else ``False`` - """ - return bool(re.compile(r'^([0-1]?[0-9]|2[0-3]):[0-5][0-9]($|\sAM|\sPM)').match(string)) - - # thematically fits in Time nicely, but does not operate on cls or self - @staticmethod - def is_range_valid(string: str) -> bool: - """ - Is the time range of :param:`str` valid? - :param string: time to check - :return: ``True`` if hour and minute ranges are both ok, ``False`` otherwise - """ - time_array = string.strip().split(':') - - # Check if it's in 12-hour format (AM/PM) or 24-hour format - if 'AM' in string or 'PM' in string: - is_hour_ok = 0 <= int(time_array[0]) <= 12 - else: - is_hour_ok = 0 <= int(time_array[0]) < 24 - - # Minutes can be checked irrespective of AM/PM/_ notation - is_minutes_ok = 0 <= int(time_array[1][:2]) < 60 - - return all((is_hour_ok, is_minutes_ok)) - - -class DatedEntry(utils.Core): - """ - Journal entry. - **A journal entry cannot exists without:** - - * Time it was written at, as :class:`Time` - * Mood, that is - a dominant emotional state during that particular moment in time. - - **Other, optional attributes:** - - * title - * note - * activities performed during or around this time - - :raises ValueError: if at least one of the required attributes cannot be set properly - """ - - def __init__(self, - time: str, - mood: str, - activities: str = None, - title: str = None, - note: str = None, - override_mood_set: Moodverse = Moodverse()): - """ - :param time: Time at which this note was created - :param mood: Mood felt during writing this note - :param activities: (opt.) Activities carried out around or at the time of writing the note - :param title: (opt.) Title of the note - :param note: (opt.) The contents of the journal note itself - :param override_mood_set: Set if you want to use custom :class:`Moodverse` for mood handling - """ - # TODO: have to test the whole instantiation function again after refactoring - self.__logger = logging.getLogger(self.__class__.__name__) - - # Processing required properties - # --- - # TIME - # --- - try: - super().__init__(Time(time)) - except IsNotTimeError as err: - errors.ErrorMsgBase.print(ErrorMsg.WRONG_TIME, time) - raise ValueError from err - - # --- - # MOOD - # --- - if not mood: - raise ValueError - # Check if the mood is valid - i.e. it does exist in the currently used Moodverse - if mood not in override_mood_set.get_moods: - errors.ErrorMsgBase.print(ErrorMsg.INVALID_MOOD, mood) - # Warning is enough, it just disables colouring so not big of a deal - self.__mood = mood - - # Processing other, optional properties - # --- - # Process activities - # --- - self.__activities = [] - if activities: - working_array = utils.strip_and_get_truthy(activities, options.csv_delimiter) - if len(working_array) > 0: - for activity in working_array: - self.__activities.append(utils.slugify( - activity, - options.tag_activities - )) - else: - errors.ErrorMsgBase.print(ErrorMsg.WRONG_ACTIVITIES) - # --- - # Process title - # --- - self.__title = utils.slice_quotes(title) if title else None - if not title: - errors.ErrorMsgBase.print(ErrorMsg.WRONG_TITLE) - # --- - # Process note - # --- - self.__note = utils.slice_quotes(note) if note else None - if not note: - errors.ErrorMsgBase.print(ErrorMsg.WRONG_NOTE) - - def output(self, stream: io.IOBase | typing.IO) -> int: - """ - Write entry contents directly into the provided buffer stream. - It is the responsibility of the caller to handle the stream afterward. - :raises utils.StreamError: if the passed stream does not support writing to it. - :raises OSError: likely due to lack of space in memory or filesystem, depending on the stream - :param stream: Since it expects the base :class:`io.IOBase` class, it accepts both file and file-like streams. - :returns: how many characters were successfully written into the stream. - """ - if not stream.writable(): - raise utils.StreamError - - chars_written = 0 - # HEADER OF THE NOTE - # e.g. "## great | 11:00 AM | Oh my, what a night!" - # options.header is an int that multiplies the # to create headers in markdown - header_elements = [ - options.header * "#" + ' ' + self.__mood, - self.time, - self.__title - ] - header = ' | '.join([el for el in header_elements if el is not None]) - chars_written += stream.write(header) - # ACTIVITIES - # e.g. "bicycle skating pool swimming" - if len(self.__activities) > 0: - chars_written += stream.write("\n" + ' '.join(self.__activities)) - # NOTE - # e.g. "Went swimming this evening." - if self.__note is not None: - chars_written += stream.write("\n" + self.__note) - - return chars_written - - @property - def mood(self): - return self.__mood - - @property - def activities(self): - return self.__activities - - @property - def title(self): - return self.__title - - @property - def note(self): - return self.__note - - @property - def time(self): - return str(self.uid) diff --git a/src/daylio_to_md/group.py b/src/daylio_to_md/group.py new file mode 100644 index 0000000..5bf05a8 --- /dev/null +++ b/src/daylio_to_md/group.py @@ -0,0 +1,284 @@ +""" +This file specialises in building the middleware between actual journal entries and the whole journal. +It creates and organises only those entries written on a particular date. This way they can be handled easier. + +Here's a quick breakdown of what is the specialisation of this file in the journaling process: +all notes -> _NOTES WRITTEN ON A PARTICULAR DATE_ -> a particular note +""" +from __future__ import annotations + +import io +import typing +import logging +import datetime +from dataclasses import dataclass, field + +from daylio_to_md import journal_entry +from daylio_to_md import utils, errors +from daylio_to_md.journal_entry import Entry +from daylio_to_md.entry.mood import Moodverse + +# TODO: dependency_injector lib +# TODO: fixtures + + +"""--------------------------------------------------------------------------------------------------------------------- +ERRORS +---------------------------------------------------------------------------------------------------------------------""" + + +class EntryMissingError(KeyError): + """The entry written at {key} does not exist in the dictionary of known entries from {date}.""" + def __init__(self, key, date): + self.__doc__ = self.__doc__.format(key=key, date=date) + super().__init__() + + +class IncompleteDataRow(Exception): + """Passed a row of data from CSV file that does not have all required fields.""" + + +class TriedCreatingDuplicateDatedEntryError(Exception): + """Tried to create object of :class:`DatedEntry` that would be a duplicate of one that already exists.""" + + +class ErrorMsg(errors.ErrorMsgBase): + CSV_ROW_INCOMPLETE_DATA = "Passed .csv contains rows with invalid data. Tried to parse {} as date." + + +"""--------------------------------------------------------------------------------------------------------------------- +MAIN +---------------------------------------------------------------------------------------------------------------------""" + + +@dataclass(frozen=True) +class BaseFileConfig: + """Stores information on how to build and configurate a :class:`DatedEntry`.""" + front_matter_tags: typing.List[str] = field(default_factory=lambda: ["daylio"]) + entry_config: journal_entry.BaseEntryConfig = field(default_factory=journal_entry.BaseEntryConfig) + + +@dataclass(frozen=True) +class FileTemplate: + config: BaseFileConfig = field(default_factory=BaseFileConfig) + + def output(self, iterable: typing.List[Entry], stream: io.IOBase | typing.IO) -> int: + """ + Write entry contents of all :class:`DatedEntry` known directly into the provided buffer stream. + It is the responsibility of the caller to handle the stream afterward. + :param iterable: + :raises utils.StreamError: if the passed stream does not support writing to it. + :raises OSError: likely due to lack of space in memory or filesystem, depending on the stream + :param stream: Since it expects the base :class:`io.IOBase` class, it accepts both file and file-like streams. + :returns: how many characters were successfully written into the stream. + """ + if not stream.writable(): + raise utils.StreamError + + chars_written = 0 + # THE BEGINNING OF THE FILE + # when appending file tags at the beginning of the file, discard any duplicates or falsy strings + # sorted() is used to have a deterministic order, set() was random, so I couldn't properly test the output + valid_tags = sorted(set(val for val in self.config.front_matter_tags if val)) + if valid_tags: + # why '\n' instead of os.linesep? + # > Do not use os.linesep as a line terminator when writing files opened in text mode (the default); + # > use a single '\n' instead, on all platforms. + # https://docs.python.org/3.10/library/os.html#os.linesep + chars_written += stream.write("---" + "\n") + chars_written += stream.write("tags: " + ",".join(valid_tags) + "\n") + chars_written += stream.write("---" + "\n" * 2) + + # THE ACTUAL ENTRY CONTENTS + # Each DatedEntry object now appends its contents into the stream + for entry in iterable: + # write returns the number of characters successfully written + # https://docs.python.org/3/library/io.html#io.TextIOBase.write + if entry.output(stream) > 0: + chars_written += stream.write("\n" * 2) + + return chars_written + + +class EntriesFrom(utils.Core): + """ + A group of entries written on the same day. + + :raises InvalidDateError: if the date cannot be type cast into :class:`datetime.date` + :param date: The date for all child entries within. Always type-casted into :class:`datetime.date` + :param mood_set: Use custom :class:`Moodverse` or default if not provided. + :param config: Use custom :class:`BaseFileConfig` or default if not provided. + """ + _instances: dict[datetime.date, EntriesFrom] = {} + + def __new__(cls, + date: typing.Union[datetime.date, str, typing.List[str]], + current_mood_set: Moodverse = Moodverse(), + config: BaseFileConfig = BaseFileConfig()): + + type_casted_date = utils.guess_date_type(date) + + # Check if an instance for the given date already exists + if type_casted_date in cls._instances: + return cls._instances[type_casted_date] + + # If not, create a new instance + instance = super(EntriesFrom, cls).__new__(cls) + cls._instances[type_casted_date] = instance + + return instance + + def __init__(self, + date: typing.Union[datetime.date, str, typing.List[str], typing.List[int]], + mood_set: Moodverse = Moodverse(), + config: BaseFileConfig = BaseFileConfig()): + super().__init__(utils.guess_date_type(date)) + + self.__logger = logging.getLogger(self.__class__.__name__) + self.config = config + + # All good - initialise + self.__known_entries: dict[datetime.time, Entry] = {} + self.__known_moods: Moodverse = mood_set + + def create_entry(self, line: dict[str, str]) -> None: + """ + Create :class:`Entry` object with the specified parameters. + Field with date is ignored, because :class:`Entry` inherits this field from parent :class:`EntriesFrom`. + The assumption here is that .create_entry() should never be called for an entry from a different date. + :raises TriedCreatingDuplicateDatedEntryError: if it would result in making a duplicate :class:`DatedEntry` + :raises IncompleteDataRow: if ``line`` does not have ``time mood`` keys at the very least, or either is empty + :raises ValueError: re-raises ValueError from :class:`DatedEntry` + :param line: a dictionary of strings. Required keys: mood, activities, note_title & note. + """ + # TODO: test case this + # Try accessing the minimum required keys + for key in ["time", "mood"]: + try: + line[key] + except KeyError as err: + raise IncompleteDataRow(key) from err + # is it empty then, maybe? + if not line[key]: + raise IncompleteDataRow(key) + + # TODO: date mismatch - this object has a different date than the full_date in line + + # Instantiate the entry + time = utils.guess_time_type(line["time"]) + this_entry = journal_entry.Entry( + time, + line["mood"], + activities=line["activities"], + title=line["note_title"], + note=line["note"], + mood_set=self.__known_moods, + config=self.config.entry_config + ) + self[time] = this_entry + + def __getitem__(self, item: typing.Union[datetime.time, str, typing.List[int], typing.List[str]]) -> Entry: + """ + Retrieve an already existing :class:`Entry` object. + e.g:: + + my_entries["10:00 AM"] + my_entries[10, 0] + my_entries[datetime.time(10, 3)] + + :param item: value that can be type-casted into a valid :class:`datetime.time` object + :raises EntryMissingError: if the entry specified in ``time`` does not exist + :returns: :class:`DatedEntry` + """ + time_lookup: datetime.time = utils.guess_time_type(item) + + if time_lookup in self.__known_entries: + return self.__known_entries[time_lookup] + else: + raise EntryMissingError(time_lookup, self.date) + + def __setitem__(self, key: typing.Union[datetime.time, str, typing.List[int], typing.List[str]], value: Entry): + """ + :param key: any of the three types that can be type-casted into valid :class:`datetime.time` object + :param value: :class:`Entry` object + :raise TypeError: if key cannot be coerced into proper object type or there is a type mismatch + """ + if not isinstance(value, Entry): + raise TypeError("Trying to assign {value} of type {type} as entry for {date}".format( + value=value, + type=type(value), + date=self.date + )) + time = utils.guess_time_type(key) + self.__known_entries[time] = value + + def add(self, *args: Entry) -> None: + """ + Add all *args into the list of known entries written on that day. + :param args: one or more :class:`Entry` objects to add + """ + for item in args: + if not isinstance(item, Entry): + continue + self[item.time] = item + + def output(self, stream: io.IOBase | typing.IO) -> int: + """ + Write entry contents of all :class:`Entry` known directly into the provided buffer stream. + It is the responsibility of the caller to handle the stream afterward. + :raises utils.StreamError: if the passed stream does not support writing to it. + :raises OSError: likely due to lack of space in memory or filesystem, depending on the stream + :param stream: Since it expects the base :class:`io.IOBase` class, it accepts both file and file-like streams. + :returns: how many characters were successfully written into the stream. + """ + if not stream.writable(): + raise utils.StreamError + + chars_written = 0 + # THE BEGINNING OF THE FILE + # when appending file tags at the beginning of the file, discard any duplicates or falsy strings + # sorted() is used to have a deterministic order, set() was random, so I couldn't properly test the output + valid_tags = sorted(set(val for val in self.config.front_matter_tags if val)) + if valid_tags: + # why '\n' instead of os.linesep? + # > Do not use os.linesep as a line terminator when writing files opened in text mode (the default); + # > use a single '\n' instead, on all platforms. + # https://docs.python.org/3.10/library/os.html#os.linesep + chars_written += stream.write("---" + "\n") + chars_written += stream.write("tags: " + ",".join(valid_tags) + "\n") + chars_written += stream.write("---" + "\n" * 2) + + # THE ACTUAL ENTRY CONTENTS + # Each DatedEntry object now appends its contents into the stream + for entry in self.__known_entries.values(): + # write returns the number of characters successfully written + # https://docs.python.org/3/library/io.html#io.TextIOBase.write + if entry.output(stream) > 0: + chars_written += stream.write("\n" * 2) + + return chars_written + + @property + def known_entries(self): + return self.__known_entries + + @property + def date(self): + """ + :return: :class:`datetime.date` object that identifies this instance of :class:`EntriesFrom`. + """ + return self.uid + + def __eq__(self, other) -> bool: + """Enables direct comparison with a :class:`datetime.date` or a date string. """ + # TODO: check if all known entries match as well, this date check is too superfluous right now + if isinstance(other, EntriesFrom): + return all([ + self.date == other.date, + self.known_entries == other.known_entries + ]) + return super().__eq__(other) + + def __str__(self): + """:return: the date that groups entries written on that day in ``YYYY-MM-DD`` format""" + return self.uid.strftime("%Y-%m-%d") diff --git a/src/daylio_to_md/journal_entry.py b/src/daylio_to_md/journal_entry.py new file mode 100644 index 0000000..6e40651 --- /dev/null +++ b/src/daylio_to_md/journal_entry.py @@ -0,0 +1,226 @@ +""" +journal_entry.py focuses on processing and outputting individual entries, made at a particular moment. It is the most +atomic level of the journaling process. + +Here's a quick breakdown of what is the specialisation of this file in the journaling process: + +└── all files + └── a file from specific day + └── **AN ENTRY FROM THAT DAY** +""" +from __future__ import annotations + +import io +import logging +import typing +import datetime +from dataclasses import dataclass + +from daylio_to_md import utils, errors +from daylio_to_md.config import options +from daylio_to_md.entry.mood import Moodverse + +"""--------------------------------------------------------------------------------------------------------------------- +ADD SETTINGS SPECIFIC TO JOURNAL ENTRIES TO ARGPARSE +---------------------------------------------------------------------------------------------------------------------""" +# Adding DatedEntry-specific options in global_settings +journal_entry_settings = options.arg_console.add_argument_group( + "Journal entry settings", + "Handles how journal entries should be formatted" +) +journal_entry_settings.add_argument( + "--tags", + help="Tags in the YAML of each note.", + nargs='*', # this allows, for example, "--tags one two three" --> ["one", "two", "three"] + default="daily", + dest="TAGS" +) +journal_entry_settings.add_argument( + "--prefix", # YYYY-MM-DD.md so remember about a separating char + default='', + help="Prepends a given string to each entry's header." +) +journal_entry_settings.add_argument( + "--suffix", # YYYY-MM-DD.md so remember about a separating char + default='', + help="Appends a given string at the end of each entry's header." +) +journal_entry_settings.add_argument( + "--tag_activities", "-a", + action="store_true", # default=True + help="Tries to convert activities into valid tags.", + dest="ACTIVITIES_AS_TAGS" +) +journal_entry_settings.add_argument( + "-colour", "--color", + action="store_true", # default=True + help="Prepends a colour emoji to each entry depending on mood.", + dest="colour" +) +journal_entry_settings.add_argument( + "--header", + type=int, + default=2, + help="Headings level for each entry.", + dest="HEADER_LEVEL" +) +journal_entry_settings.add_argument( + "--csv-delimiter", + default="|", + help="Set delimiter for activities in Daylio .CSV, e.g: football | chess" +) + +"""--------------------------------------------------------------------------------------------------------------------- +ERRORS +---------------------------------------------------------------------------------------------------------------------""" + + +class NoMoodError(utils.ExpectedValueError): + """Required non-empty mood.""" + def __init__(self, expected_value, actual_value): + super().__init__(expected_value, actual_value) + + +class ErrorMsg(errors.ErrorMsgBase): + INVALID_MOOD = "Mood {} is missing from a list of known moods. Not critical, but colouring won't work on the entry." + WRONG_TIME = "Received {}, expected valid time. Cannot create this entry without a valid time." + WRONG_ACTIVITIES = "Received a non-empty string containing activities. Parsing it resulted in an empty list." + + +"""--------------------------------------------------------------------------------------------------------------------- +MAIN +---------------------------------------------------------------------------------------------------------------------""" + + +@dataclass(frozen=True) +class BaseEntryConfig: + """Stores information on how to build and configurate a :class:`DatedEntry`.""" + csv_delimiter: str = '|' + header_multiplier: int = 2 + tag_activities: bool = True + + +class Entry(utils.Core): + """ + Journal entry + + :param time: Time at which this note was created. It will be type-casted into :class:`datetime.time` object. + :param mood: Mood felt during writing this note. + It can be any :class:`str`, but during output the colouring won't work if the mood is not recognised. + (i.e `joyful` won't appear green if it isn't in the ``moods.json``) + :param activities: (opt.) Activities carried out around or at the time of writing the note + :param title: (opt.) Title of the note + :param note: (opt.) The contents of the journal note itself + :param config: (opt). Configures how an entry should be processed or outputted + :param mood_set: (opt.) Set if you want to use custom :class:`Moodverse` for mood handling + :raise InvalidTimeError: if the passed time argument cannot be coerced into :class:`datetime.time` + :raise NoMoorError: if mood is falsy + """ + + def __init__(self, + time: typing.Union[datetime.time, str, typing.List[int], typing.List[int]], + mood: str, + activities: str = None, + title: str = None, + note: str = None, + config: BaseEntryConfig = BaseEntryConfig(), + mood_set: Moodverse = Moodverse()): + + # TODO: have to test the whole instantiation function again after refactoring + self.__logger = logging.getLogger(self.__class__.__name__) + self.config = config + + # Processing required properties + # --- + # TIME + # --- + super().__init__(utils.guess_time_type(time)) + + # --- + # MOOD + # --- + if not mood: + raise NoMoodError("any truthy string as mood", mood) + + # Check if the mood is valid - i.e. it does exist in the currently used Moodverse + if mood not in mood_set.get_moods: + self.__logger.warning(ErrorMsg.INVALID_MOOD.format(mood)) + # Warning is enough, it just disables colouring so not big of a deal + self.__mood = mood + + # Processing other, optional properties + # --- + # Process activities + self.__activities = [] + if activities: + working_array = utils.strip_and_get_truthy(activities, config.csv_delimiter) + if len(working_array) > 0: + for activity in working_array: + self.__activities.append(utils.slugify( + activity, + config.tag_activities + )) + else: + self.__logger.warning(ErrorMsg.WRONG_ACTIVITIES.format(activities)) + # Process title + self.__title = utils.slice_quotes(title) if title else None + # Process note + self.__note = utils.slice_quotes(note) if note else None + + def output(self, stream: io.IOBase | typing.IO) -> int: + """ + Write entry contents directly into the provided buffer stream. + It is the responsibility of the caller to handle the stream afterward. + :param stream: Since it expects the base :class:`io.IOBase` class, it accepts both file and file-like streams. + :raises utils.StreamError: if the passed stream does not support writing to it. + :raises OSError: likely due to lack of space in memory or filesystem, depending on the stream + :returns: how many characters were successfully written into the stream. + """ + if not stream.writable(): + raise utils.StreamError + + chars_written = 0 + # HEADER OF THE NOTE + # e.g. "## great | 11:00 AM | Oh my, what a night!" + # header_multiplier is an int that multiplies the # to create headers in markdown + header_elements = [ + self.config.header_multiplier * "#" + ' ' + self.__mood, + self.time.strftime("%H:%M"), + self.__title + ] + header = ' | '.join([el for el in header_elements if el is not None]) + chars_written += stream.write(header) + # ACTIVITIES + # e.g. "bicycle skating pool swimming" + if len(self.__activities) > 0: + chars_written += stream.write("\n" + ' '.join(self.__activities)) + # NOTE + # e.g. "Went swimming this evening." + if self.__note is not None: + chars_written += stream.write("\n" + self.__note) + + return chars_written + + @property + def mood(self) -> str: + return self.__mood + + @property + def activities(self) -> typing.List[str]: + return self.__activities + + @property + def title(self) -> str: + return self.__title + + @property + def note(self) -> str: + return self.__note + + @property + def time(self) -> datetime.time: + return self.uid + + def __str__(self): + """:return: the time at which an entry was written in ``HH:MM`` format""" + return self.uid.strftime("%H:%M") diff --git a/src/daylio_to_md/librarian.py b/src/daylio_to_md/librarian.py index 13c8634..96c5a8b 100644 --- a/src/daylio_to_md/librarian.py +++ b/src/daylio_to_md/librarian.py @@ -1,5 +1,5 @@ """ -Librarian is a builder/singleton type class. It creates and initialises other builder objects - e.g. DatedEntriesGroup. +Librarian is a Director type class. It creates and initialises other builder objects - e.g. DatedEntriesGroup. It sets up the process, parses the CSV file and passes extracted values to DatedEntriesGroup. Imagine Librarian is an actual person, reading the contents of the file out-loud to a scribe (DatedEntriesGroup). Each date is a different scribe, but they all listen to the same Librarian. @@ -7,26 +7,31 @@ Here's a quick breakdown of what is the specialisation of this file in the journaling process: -``└── ALL NOTES`` - ``└── all notes written on a particular date`` - ``└── a particular note`` +└── **ALL NOTES** + └── a file from specific day + └── an entry from that day """ from __future__ import annotations -import logging import os +import typing +import logging +import datetime from typing import IO -from daylio_to_md import utils, errors, dated_entries_group from daylio_to_md.config import options -from daylio_to_md.dated_entries_group import DatedEntriesGroup +from daylio_to_md import utils, errors, group from daylio_to_md.entry.mood import Moodverse -from daylio_to_md.utils import CsvLoader, JsonLoader, CouldNotLoadFileError +from daylio_to_md.group import EntriesFrom, BaseFileConfig +from daylio_to_md.utils import CsvLoader, JsonLoader, CouldNotLoadFileError, guess_date_type + +"""--------------------------------------------------------------------------------------------------------------------- +ADD MAIN SETTINGS TO ARGPARSE +---------------------------------------------------------------------------------------------------------------------""" # Adding Librarian-specific options in global_settings librarian_settings = options.arg_console.add_argument_group( - "Librarian", - "Handles main options" + "Main options" ) # 1. Filepath is absolutely crucial to even start processing librarian_settings.add_argument( @@ -45,9 +50,13 @@ "--force", choices=["accept", "refuse"], default=None, - help="Skips user confirmation when overwriting files and auto-accepts or auto-refuses all requests." + help="Instead of asking for confirmation every time when overwriting files, accept or refuse all such requests." ) +"""--------------------------------------------------------------------------------------------------------------------- +ERRORS +---------------------------------------------------------------------------------------------------------------------""" + class ErrorMsg(errors.ErrorMsgBase): FILE_INCOMPLETE = "{} is incomplete." @@ -62,28 +71,44 @@ class ErrorMsg(errors.ErrorMsgBase): COUNT_ROWS = "{} rows of data found in {}. Of that {} were processed correctly." -class MissingValuesInRowError(utils.CustomException): - """If a CSV row does not have enough values needed to create an entry.""" +class MissingValuesInRowError(utils.ExpectedValueError): + """The row does not have enough cells - {} needed, {} available.""" + + def __init__(self, cells_expected, cells_got): + super().__init__(cells_expected, cells_got) -class CannotAccessFileError(utils.CustomException): - """The file could not be accessed.""" +class CannotAccessJournalError(utils.CouldNotLoadFileError): + """The journal .csv {} could not be accessed or parsed.""" + def __init__(self, path: str): + super().__init__(path) -class CannotAccessJournalError(CannotAccessFileError): - """The journal CSV could not be accessed or parsed.""" +class EmptyJournalError(utils.CouldNotLoadFileError): + """The journal .csv {} did not produce any valid journal entries.""" -class CannotAccessCustomMoodsError(CannotAccessFileError): - """The custom moods JSON could not be accessed or parsed.""" + def __init__(self, path: str): + super().__init__(path) -class InvalidDataInFileError(utils.CustomException): +class CannotAccessCustomMoodsError(utils.CouldNotLoadFileError): + """The custom moods .json {} could not be accessed or parsed.""" + + def __init__(self, path: str): + super().__init__(path) + + +class InvalidDataInFileError(utils.ExpectedValueError): """The file does not follow the expected structure.""" + def __init__(self, expected_value, actual_value): + super().__init__(expected_value, actual_value) + -class NoDestinationSelectedError(utils.CustomException): - """You have not specified where to output the files when instantiating this Librarian object.""" +"""--------------------------------------------------------------------------------------------------------------------- +MAIN +---------------------------------------------------------------------------------------------------------------------""" def create_and_open(filename: str, mode: str) -> IO: @@ -100,34 +125,39 @@ class Librarian: ``Librarian`` -> :class:`DatedEntriesGroup` -> :class:`DatedEntries` - --- - - **How to process the CSV** - + How to process the CSV + ---------------------- User only needs to instantiate this object and pass the appropriate arguments. The processing does not require invoking any other functions. Functions of this class are therefore mostly private. - --- - - **How to output the journal** - + How to output the journal + ------------------------- TODO: add missing documentation """ def __init__(self, - path_to_file: str, # the only crucial parameter at this stage + path_to_file: str, path_to_output: str = None, - path_to_moods: str = None): + path_to_moods: str = None, + config: BaseFileConfig = BaseFileConfig()): """ :param path_to_file: The path to the CSV file for processing. :param path_to_output: The path for outputting processed data as markdown files. - If user does not provide the output path, no output functionality will work. - :raises CannotAccessFileError: if any problems occur during accessing or decoding the CSV file. + If user does not provide the output path, no output functionality will work. :param path_to_moods: The path for a custom mood set file. + :raises CannotAccessFileError: if any problems occur during accessing or decoding the CSV file. + :raises EmptyJournalError: if the file does not produce any valid results after processing. """ self.__logger = logging.getLogger(self.__class__.__name__) - self.__known_dates: dict[str, DatedEntriesGroup] = {} + self.__known_dates: dict[datetime.date, EntriesFrom] = {} + self.__config = config + + self.__start(path_to_file, path_to_output, path_to_moods) + def __start(self, + path_to_file: str, + path_to_output: str, + path_to_moods: str): # Let's start processing the file # --- # 1. Parse the path_to_moods JSON for a custom mood set @@ -137,11 +167,11 @@ def __init__(self, # 2. Access the CSV file and get all the rows with content # then pass the data to specialised data objects that can handle them in a structured way - # TODO: Deal with files that are valid but at the end of parsing have zero lines successfully parsed try: - self.__process_file(path_to_file) + if self.__process_file(path_to_file)[0] == 0: + raise EmptyJournalError(path_to_file) except (CouldNotLoadFileError, InvalidDataInFileError) as err: - raise CannotAccessJournalError from err + raise CannotAccessJournalError(path_to_file) from err # Ok, if no exceptions were raised so far, the file is good, let's go through the rest of the attributes self.__destination = path_to_output @@ -159,24 +189,21 @@ def __create_mood_set(self, filepath: str = None) -> 'Moodverse': """ try: with JsonLoader().load(filepath) as file: - # if there's a non-empty path - if filepath: - return Moodverse(file) + return Moodverse(file) except utils.CouldNotLoadFileError: - # oh no! anyway... just load up a default moodverse then + # oh, no! anyway... just load up a default moodverse then return Moodverse() - # TODO: should return a tuple of { lines_processed_correctly, all_lines_processed } - def __process_file(self, filepath: str) -> bool: + def __process_file(self, filepath: str) -> typing.Tuple[int, int]: """ Validates CSV file and processes it into iterable rows. :param filepath: path to CSV to be read :raises CannotAccessFileError: if any problems occur during accessing the CSV file. :raises InvalidDataInFileError: if any problems occur during parsing the CSV file. - :returns: True if parsed > 0, False otherwise + :returns: ``[lines_parsed_correctly, all_lines_parsed]`` """ if not self.__mood_set.get_custom_moods: - self.__logger.info(ErrorMsg.print(ErrorMsg.STANDARD_MOODS_USED)) + self.__logger.info(ErrorMsg.STANDARD_MOODS_USED) # Open file # --- @@ -184,6 +211,7 @@ def __process_file(self, filepath: str) -> bool: # If any ValueError Exception is re-raised up to this method, just exit immediately - no point going further try: with CsvLoader().load(filepath) as file: + # TODO: move validation into CsvLoader maybe # If the code reaches here, the program can access the file. # Now let's determine if the file's contents are actually usable # --- @@ -208,103 +236,108 @@ def __process_file(self, filepath: str) -> bool: expected_field not in file.fieldnames ] if not missing_strings: - self.__logger.debug(ErrorMsg.print(ErrorMsg.CSV_ALL_FIELDS_PRESENT)) + self.__logger.debug(ErrorMsg.CSV_ALL_FIELDS_PRESENT) else: - msg = ErrorMsg.print( - ErrorMsg.CSV_FIELDS_MISSING, - ', '.join(missing_strings) # which ones are missing - e.g. "date, mood, note" - ) + msg = ErrorMsg.CSV_FIELDS_MISSING.format(', '.join(missing_strings)) self.__logger.critical(msg) - raise InvalidDataInFileError(msg) + raise InvalidDataInFileError(file.fieldnames, msg) # Processing # --- lines_parsed = 0 lines_parsed_successfully = 0 for line in file: - line: dict[str] + line: dict[str, str] try: lines_parsed += self.__process_line(line) - except MissingValuesInRowError: - pass + except MissingValuesInRowError as err: + self.__logger.warning(err.__doc__) else: lines_parsed_successfully += 1 - - # Report back how many lines were parsed successfully out of all tried - self.__logger.info(ErrorMsg.print( - ErrorMsg.COUNT_ROWS, str(lines_parsed), filepath, str(lines_parsed_successfully)) - ) except ValueError as err: - raise CannotAccessJournalError from err + raise CannotAccessJournalError(filepath) from err - # If at least one line has been parsed, the following return resolves to True - return bool(lines_parsed) + # Report back how many lines were parsed successfully out of all tried + self.__logger.info(ErrorMsg.COUNT_ROWS.format(lines_parsed, filepath, lines_parsed_successfully)) + return lines_parsed_successfully, lines_parsed # TODO: I guess it is more pythonic to raise exceptions than return False if I cannot complete the task # TODO: this has to be tested # https://eli.thegreenplace.net/2008/08/21/robust-exception-handling/ - def __process_line(self, line: dict[str]) -> bool: + def __process_line(self, line: dict[str, str]) -> bool: """ Goes row-by-row and passes the content to objects specialised in handling it from a journaling perspective. - :raises MissingValuesInRowError: if the row in CSV lacks enough commas to create 8 cells. It signals a problem. :param line: a dictionary with values from the currently processed CSV line :return: True if all columns had values for this CSV ``line``, False otherwise + :raises MissingValuesInRowError: if the row in CSV lacks enough commas to create 8 cells. It signals a problem. """ + # noinspection PyPep8Naming + EXPECTED_NUM_OF_CELLS = 8 # Does each of the 8 columns have values for this row? - if len(line) < 8: + if len(line) < EXPECTED_NUM_OF_CELLS: # Oops, not enough values on this row, the file might be corrupted? - msg = ErrorMsg.print(ErrorMsg.FILE_INCOMPLETE, str(line)) - self.__logger.warning(msg) - raise MissingValuesInRowError(msg) + raise MissingValuesInRowError(EXPECTED_NUM_OF_CELLS, len(line)) + # Let DatedEntriesGroup handle the rest and increment the counter (True == 1) try: - self.access_date(line["full_date"]).create_dated_entry_from_row(line) - except (dated_entries_group.TriedCreatingDuplicateDatedEntryError, - dated_entries_group.IncompleteDataRow, - dated_entries_group.InvalidDateError, + date = guess_date_type(line["full_date"]) + entries_from_this_date = EntriesFrom(date, config=self.__config) + entries_from_this_date.create_entry(line) + # Overwriting existing keys is not a problem since EntriesFrom.__new__() returns the same object ID when + # it is initialised with the same date parameter. Also, since we are type-casting date into datetime.date + # identical dates will always be equal, so the same key-value pair will be returned by the dictionary. + self[date] = entries_from_this_date + except (group.TriedCreatingDuplicateDatedEntryError, + group.IncompleteDataRow, + utils.InvalidDateError, ValueError): return False return True - def access_date(self, target_date: str) -> DatedEntriesGroup: - """ - Accesses an already existing or creates a new :class:`DatedEntriesGroup` for the specified ``target_date``. - :raises ValueError: if ``target_date`` is an invalid Date as indicated by :class:`Date` object - :param target_date: the date for which a unique :class:`DatedEntriesGroup` object should be created or accessed. - :return: reference to :class:`DatedEntriesGroup` object - """ - try: - date_lookup = dated_entries_group.Date(target_date) - except dated_entries_group.InvalidDateError as err: - raise ValueError from err - - if str(date_lookup) in self.__known_dates: - return self.__known_dates[str(date_lookup)] - else: - new_obj = DatedEntriesGroup(str(date_lookup), self.__mood_set) - self.__known_dates[str(date_lookup)] = new_obj - return new_obj - def output_all(self): """ Loops through known dates and calls :class:`DatedEntriesGroup` to output its contents inside the destination. :raises NoDestinationSelectedError: when the parent object has been instantiated without a destination set. """ - if self.__destination is None: - raise NoDestinationSelectedError - for known_date in self.__known_dates.values(): # "2022/11/09/2022-11-09.md" filename = str(known_date.date) + ".md" - filepath = "/".join([self.__destination, known_date.date.year, known_date.date.month, filename]) - with create_and_open(filepath, 'a') as file: + filepath = "/".join([self.__destination, str(known_date.date.year), str(known_date.date.month), filename]) + # TODO: maybe add the mode option to settings in argparse? write/append + with create_and_open(filepath, 'w') as file: known_date.output(file) - # Use a dunder overload of getitem to access groups in either way - # 1. my_librarian["2022-10-10"] - # 2. my_librarian.access_date("2022-10-10") - def __getitem__(self, item: str) -> DatedEntriesGroup: - return self.access_date(item) + def __getitem__(self, key: typing.Union[datetime.date, str, typing.List[str], typing.List[int]]) -> EntriesFrom: + """ + Accesses an already existing :class:`EntriesFrom` for the specified ``key`` as target date. + e.g.:: + + my_librarian[datetime.date(2022, 10, 10)] + my_librarian["2022-10-10"] + my_librarian[[2022, 10, 10]] + + :raises KeyError: if key cannot be found in the dictionary of known dates. + :return: reference to :class:`DatedEntriesGroup` object + """ + date_lookup: datetime.date = guess_date_type(key) + + if date_lookup in self.__known_dates: + return self.__known_dates[date_lookup] + # TODO: custom exception like EntryMissingError + raise KeyError + + def __setitem__(self, + key: typing.Union[datetime.date, str, typing.List[str], typing.List[int]], + value: EntriesFrom): + """ + :param key: any of the three types that can be type-casted into valid :class:`datetime.date` object + :param value: :class:`EntriesFrom` object + :raise TypeError: if key cannot be coerced into proper object type or there is a type mismatch + """ + if not isinstance(value, EntriesFrom): + raise TypeError + date = guess_date_type(key) + self.__known_dates[date] = value @property def current_mood_set(self): diff --git a/src/daylio_to_md/utils.py b/src/daylio_to_md/utils.py index 4bf6737..19731e6 100755 --- a/src/daylio_to_md/utils.py +++ b/src/daylio_to_md/utils.py @@ -1,22 +1,88 @@ """ Contains universally useful functions """ +from __future__ import annotations + import abc import csv +import datetime import json import logging import os import re +import typing from contextlib import contextmanager from typing import List, TextIO, Optional from daylio_to_md import errors +"""--------------------------------------------------------------------------------------------------------------------- +ERRORS +---------------------------------------------------------------------------------------------------------------------""" + class ErrorMsg(errors.ErrorMsgBase): INVALID_OBSIDIAN_TAGS = "You want your activities as tags, but {} is invalid." +class ExpectedValueError(TypeError): + """Expected {}, got {} instead.""" + + def __init__(self, expected_value, actual_value): + super().__init__() + self.__expected_value = expected_value + self.__actual_value = actual_value + try: + self.__doc__ = self.__doc__.format(expected_value, actual_value) + except KeyError: + pass + + @property + def expected_value(self): + return self.__expected_value + + @property + def actual_value(self): + return self.__actual_value + + +# It is open to interpretation whether an invalid date is more of a TypeError or ValueError Exception +class InvalidDateError(ExpectedValueError, ValueError): + """String {} is not a valid date. Check :class:`datetime.Date` for details.""" + + def __init__(self, date_passed): + super().__init__("YYYY-MM-DD", str(date_passed)) + + +class InvalidTimeError(ExpectedValueError): + """String {} is not a valid date. Check :class:`datetime.Time` for details.""" + + def __init__(self, time_passed): + super().__init__("HH:MM with optional AM:PM suffix", str(time_passed)) + + +class CouldNotLoadFileError(Exception): + """The file {} could not be accessed.""" + + def __init__(self, path: str): + super().__init__() + self.__path = path + self.__doc__ = self.__doc__.format(self.__path) + + @property + def path(self): + return self.__path + + +class StreamError(Exception): + pass + + +"""--------------------------------------------------------------------------------------------------------------------- +MAIN +---------------------------------------------------------------------------------------------------------------------""" + + class Core: def __init__(self, uid): self.__uid = uid @@ -30,25 +96,14 @@ def __str__(self): def __hash__(self): return hash(self.uid) + def __repr__(self): + return "{object}({uid})".format(object=self.__class__.__name__, uid=self.uid) + @property def uid(self): return self.__uid -class CustomException(Exception): - def __init__(self, message=None): - super().__init__(message) - self.message = message - - -class CouldNotLoadFileError(Exception): - pass - - -class StreamError(CustomException): - pass - - def slugify(text: str, taggify: bool) -> str: # noinspection SpellCheckingInspection """ @@ -127,7 +182,7 @@ def load(self, path: str) -> None: yield self._load_file(file) # TypeError is thrown when a None argument is passed except (FileNotFoundError, PermissionError, OSError, UnicodeDecodeError, TypeError) as err: - raise CouldNotLoadFileError from err + raise CouldNotLoadFileError(path) from err class JsonLoader(FileLoader): @@ -147,3 +202,107 @@ def _load_file(self, file: TextIO): # CSV specific errors except csv.Error as err: raise CouldNotLoadFileError from err + + +"""--------------------------------------------------------------------------------------------------------------------- +DATE AND TIME +---------------------------------------------------------------------------------------------------------------------""" + + +def guess_date_type(this: typing.Union[datetime.date, str, typing.List[str], typing.List[int]]) -> datetime.date: + """ + Supported formats + ----------------- + - "%Y-%m-%d" - ISO 8601 format + - "%d/%m/%Y" - Day/Month/Year format + - "%m/%d/%Y" - Month/Day/Year format + - "%B %d, %Y" - Month name, day, year + - "%d %B %Y" - Day, month name, year + - "%Y%m%d" - Basic ISO format (no separators) + + Examples + -------- + - "%Y-%m-%d" -> "2023-05-15" + - "%d/%m/%Y" -> "15/05/2023" + - "%m/%d/%Y" -> "05/15/2023" + - "%B %d, %Y" -> "May 15, 2023" + - "%d %B %Y" -> "15 May 2023" + - "%Y%m%d" -> "20230515" + + :param this: date to be coerced into :class:`datetime.date` object. Strips leading and trailing spaces if a string. + :raise InvalidDateError: if it cannot be coerced into proper object type + :return: :class:`datetime.date` object + """ + proper_date_obj: datetime.date + + if isinstance(this, str): + this = this.strip() + formats = ["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%B %d, %Y", "%d %B %Y", "%Y%m%d"] + for fmt in formats: + try: + return datetime.datetime.strptime(this, fmt).date() + except ValueError: + continue + raise InvalidDateError(this) + elif isinstance(this, typing.List) and len(this) == 3: + year, month, day = (int(el) for el in this) + try: + proper_date_obj = datetime.date(year, month, day) + except ValueError as err: + raise InvalidDateError(this) from err + elif isinstance(this, datetime.date): + proper_date_obj = this + else: + raise InvalidDateError(this) + + return proper_date_obj + + +def guess_time_type(this: typing.Union[datetime.time, str, typing.List[str], typing.List[int]]) -> datetime.time: + """ + Supported formats + ------------------ + - "%I:%M %p" - 12-hour format with AM/PM + - "%I:%M%p" - 12-hour format with AM/PM but without a space as a delimiter + - "%H:%M" - 24-hour format + - "%-I:%M %p" - 12-hour format with AM/PM, no leading zero for hour + - "%-H:%M" - 24-hour format, no leading zero for hour + + Examples + -------- + - "%I:%M %p" -> "01:30 PM", "12:45 AM" + - "%I:%M%p" -> "11:45PM", "03:55AM" + - "%H:%M" -> "13:30", "00:45" + - "%-I:%M %p" -> "1:30 PM", "12:45 AM" + - "%-I:%M%p" -> "10:00PM", "6:10AM" + - "%-H:%M" -> "13:30", "0:45" + + :param this: time to be coerced into :class:`datetime.time` object. Strips leading and trailing spaces if a string. + :raise InvalidTimeError: if ``this`` cannot be coerced into proper object type + :return: :class:`datetime.time` object + """ + proper_time_obj: datetime.time + + if isinstance(this, str): + formats = ["%I:%M %p", "%I:%M%p", "%H:%M", "%-I:%M %p", "%-I:%M%p", "%-H:%M"] + for fmt in formats: + try: + # https://stackoverflow.com/questions/3183707/stripping-off-the-seconds-in-datetime-python + proper_time_obj = datetime.datetime.strptime(this.strip(), fmt).time() + break + except ValueError: + continue + else: + raise InvalidTimeError(this) + elif isinstance(this, typing.List) and len(this) == 2: + hours, minutes = (int(el) for el in this) + try: + proper_time_obj = datetime.time(hours, minutes) + except ValueError as err: + raise InvalidTimeError(this) from err + elif isinstance(this, datetime.time): + proper_time_obj = this + else: + raise InvalidTimeError(this) + + return proper_time_obj.replace(second=0, microsecond=0) diff --git a/tests/files/journal_CSVs/empty_sheet.csv b/tests/files/all-valid.csv similarity index 100% rename from tests/files/journal_CSVs/empty_sheet.csv rename to tests/files/all-valid.csv diff --git a/tests/files/all-valid.json b/tests/files/all-valid.json new file mode 120000 index 0000000..e36d0eb --- /dev/null +++ b/tests/files/all-valid.json @@ -0,0 +1 @@ +../../moods.json \ No newline at end of file diff --git a/tests/files/journal_CSVs/sheet-1-valid-data.csv b/tests/files/journal_CSVs/sheet-1-valid-data.csv deleted file mode 100644 index a021e19..0000000 --- a/tests/files/journal_CSVs/sheet-1-valid-data.csv +++ /dev/null @@ -1,13 +0,0 @@ -full_date,date,weekday,time,mood,activities,note_title,note -2022-10-30,October 30,Sunday,10:04 AM,vaguely ok,2ćities skylines | dó#lóó fa$$s_ą%,"Dolomet","Lorem ipsum sit dolomet amęt." -2022-10-27,October 27,Thursday,1:49 PM,vaguely good,chess,"Cras pretium","Lorem ipsum dolor sit amet, consectetur adipiscing elit." -2022-10-27,October 27,Thursday,12:00 AM,fatigued,allegro | working remotely,"Suspendisse sit amet","Phaśellus pharetra justo ac dui lacinia ullamcorper." -2022-10-26,October 26,Wednesday,10:00 PM,captivated,at the office | board game | colleague interaction | big social gathering,,"Sed ut est interdum","Maecenas dictum augue in nibh pellentesque porttitor." -2022-10-26,October 26,Wednesday,8:00 PM,tired,allegro | at the office | board game | colleague interaction | big social gathering,"Mauris rutrum diam","Quisque dictum odio quis augue consectetur, at convallis żodio aliquam." -2022-10-26,October 26,Wednesday,7:30 PM,grateful,allegro | at the office | acknowledged efforts | colleague interaction,"Aliquam nec sem semper","Nulla aćcumsan sem sit amet lectus pretium, ac interdum tellus porta." -2022-10-26,October 26,Wednesday,1:00 PM,blissful,allegro | at the office,"Vestibulum sagittis leo eu sodales","Ut et elit id lectus hendrerit ełementum quis auctor ipsum." -2022-10-26,October 26,Wednesday,9:00 AM,in awe,allegro | at the office | outdoors | notable event,"Integer elementum","Nunc lobortis enim eu nisi ultrices, sit amet sagittis lacus venenatis." -2022-10-26,October 26,Wednesday,7:50 AM,lifeless,podcast | politics | world event,"Nulla quis lectus pulvinar","Etiam commódo enim ut orci varius viverra." -2022-10-25,October 25,Tuesday,11:36 PM,hungry,allegro | working remotely | colleague interaction,"Mauris vitae nunc vel arcu consequat auctor","Nulla vel risus eget magna lacinia aliquam ac in arcu." -2022-10-25,October 25,Tuesday,11:40 PM,rad,,,Uet nulla nunc lobortis quisque. -2022-10-25,October 25,Tuesday,5:00 PM,vaguely ok,,, \ No newline at end of file diff --git a/tests/files/journal_CSVs/sheet-4-no-extension b/tests/files/journal_CSVs/sheet-4-no-extension deleted file mode 100644 index 44f254f..0000000 Binary files a/tests/files/journal_CSVs/sheet-4-no-extension and /dev/null differ diff --git a/tests/files/mood_JSONs/moods.json b/tests/files/mood_JSONs/moods.json deleted file mode 100644 index 0a89e6e..0000000 --- a/tests/files/mood_JSONs/moods.json +++ /dev/null @@ -1,102 +0,0 @@ -{ - "rad": [ - "rad", - "blissful", - "excited", - "relieved", - "lecturing", - "beyond pleasure" - ], - "good": [ - "vaguely good", - "captivated", - "appreciated", - "authoritative", - "aroused", - "in awe", - "very relaxed", - "laughing", - "schadenfreude", - "grateful", - "proud", - "part of a group", - "relived", - "hopeful", - "social", - "on edge", - "loving", - "rested", - "happy exercise" - ], - "neutral": [ - "vaguely ok", - "a bit helpless", - "fatigued", - "scared", - "bored", - "uneasy", - "amused", - "focused", - "relaxed", - "intrigued", - "somewhat rested", - "in a hurry", - "conflicted", - "surprised", - "bit distracted", - "reflective", - "indifferent", - "groggy", - "cheering up", - "refreshed" - ], - "bad": [ - "vaguely bad", - "helpless", - "misunderstood", - "rejected", - "incompetent", - "tired", - "stressed", - "terrified", - "very bored", - "angry", - "aching", - "envy", - "disgusted", - "lonely", - "distracted", - "cold", - "impatient", - "hot", - "cringe", - "uncomfortable", - "skimpy", - "guilty", - "sexual unease", - "hungry", - "disappointed", - "annoyed", - "melting brain", - "pass-aggressive", - "hurt emotionally" - ], - "awful": [ - "vaguely awful", - "hollow", - "trapped", - "dying of pain", - "furious", - "mortified", - "worthless", - "longing", - "sexually tense", - "guilt-ridden", - "lifeless", - "nauseous", - "very stressed", - "overwhelmed", - "crying", - "heart-stabbed" - ] -} diff --git a/tests/files/mood_JSONs/incomplete-moods.json b/tests/files/moods/incomplete.json similarity index 100% rename from tests/files/mood_JSONs/incomplete-moods.json rename to tests/files/moods/incomplete.json diff --git a/tests/files/mood_JSONs/smallest_moodset_possible.json b/tests/files/moods/smallest.json similarity index 100% rename from tests/files/mood_JSONs/smallest_moodset_possible.json rename to tests/files/moods/smallest.json diff --git a/tests/files/journal_CSVs/sheet-2-corrupted-bytes.csv b/tests/files/scenarios/fail/corrupted.csv similarity index 100% rename from tests/files/journal_CSVs/sheet-2-corrupted-bytes.csv rename to tests/files/scenarios/fail/corrupted.csv diff --git a/tests/files/journal_CSVs/sheet-6-empty-file.csv b/tests/files/scenarios/fail/empty.csv similarity index 100% rename from tests/files/journal_CSVs/sheet-6-empty-file.csv rename to tests/files/scenarios/fail/empty.csv diff --git a/tests/files/locked-dir/locked_file.csv b/tests/files/scenarios/fail/locked.csv similarity index 100% rename from tests/files/locked-dir/locked_file.csv rename to tests/files/scenarios/fail/locked.csv diff --git a/tests/files/journal_CSVs/sheet-3-wrong-format.txt b/tests/files/scenarios/fail/wrong-format.txt similarity index 100% rename from tests/files/journal_CSVs/sheet-3-wrong-format.txt rename to tests/files/scenarios/fail/wrong-format.txt diff --git a/tests/files/scenarios/ok/all-valid.csv b/tests/files/scenarios/ok/all-valid.csv new file mode 120000 index 0000000..2f7f09b --- /dev/null +++ b/tests/files/scenarios/ok/all-valid.csv @@ -0,0 +1 @@ +../../all-valid.csv \ No newline at end of file diff --git a/tests/files/expected_results/2022-10-25.md b/tests/files/scenarios/ok/expect/2022-10-25.md similarity index 100% rename from tests/files/expected_results/2022-10-25.md rename to tests/files/scenarios/ok/expect/2022-10-25.md diff --git a/tests/files/expected_results/2022-10-26.md b/tests/files/scenarios/ok/expect/2022-10-26.md similarity index 100% rename from tests/files/expected_results/2022-10-26.md rename to tests/files/scenarios/ok/expect/2022-10-26.md diff --git a/tests/files/expected_results/2022-10-27.md b/tests/files/scenarios/ok/expect/2022-10-27.md similarity index 100% rename from tests/files/expected_results/2022-10-27.md rename to tests/files/scenarios/ok/expect/2022-10-27.md diff --git a/tests/files/expected_results/2022-10-30.md b/tests/files/scenarios/ok/expect/2022-10-30.md similarity index 100% rename from tests/files/expected_results/2022-10-30.md rename to tests/files/scenarios/ok/expect/2022-10-30.md diff --git a/tests/files/scenarios/ok/no-extension b/tests/files/scenarios/ok/no-extension new file mode 100644 index 0000000..fe9c98e --- /dev/null +++ b/tests/files/scenarios/ok/no-extension @@ -0,0 +1,2 @@ +full_date,date,weekday,time,mood,activities,note_title,note +2022-10-30,October 30,Sunday,10:04 AM,vaguely ok,2ćities skylines | dó#lóó fa$$s_ą%,"Dolomet","Lorem ipsum sit dolomet amęt." \ No newline at end of file diff --git a/tests/files/scenarios/partially-ok/empty-lines-whitespace.csv b/tests/files/scenarios/partially-ok/empty-lines-whitespace.csv new file mode 100644 index 0000000..ad74004 --- /dev/null +++ b/tests/files/scenarios/partially-ok/empty-lines-whitespace.csv @@ -0,0 +1,9 @@ +full_date,date,weekday,time,mood,activities,note_title,note + +2022-10-30, October 30 , Sunday,10:04 AM, vaguely ok ,2ćities skylines | dó#lóó fa$$s_ą%, "Dolomet" ," Lorem ipsum sit dolomet amęt. " +2022-10-27,October 27,Thursday,1:49 PM,vaguely good,chess,"Cras pretium","Lorem ipsum dolor sit amet, consectetur adipiscing elit." + +2022-10-27,October 27,Thursday,12:00 AM,fatigued, working remotely ,"Suspendisse sit amet","Phaśellus pharetra justo ac dui lacinia ullamcorper." +2022-10-26,October 26,Wednesday,10:00 PM,captivated,big social gathering,,"Sed ut est interdum","Maecenas dictum augue in nibh pellentesque porttitor." + +2022-10-26,October 26,Wednesday,8:00 PM,tired,| at the office | board game | colleague interaction | big social gathering |,"Mauris rutrum diam","Quisque dictum odio quis augue consectetur, at convallis żodio aliquam." diff --git a/tests/files/scenarios/partially-ok/escaped-characters.csv b/tests/files/scenarios/partially-ok/escaped-characters.csv new file mode 100644 index 0000000..fa73d58 --- /dev/null +++ b/tests/files/scenarios/partially-ok/escaped-characters.csv @@ -0,0 +1,6 @@ +full_date,date,weekday,time,mood,activities,note_title,note +2022-10-30,October 30,Sunday,10:04 AM,vaguely ok,"sos | ""quoted activity""","Dolomet ""with quotes""","Lorem ipsum sit ""dolomet"" amęt." +2022-10-27,October 27,Thursday,1:49 PM,vaguely good,chess,"Cras ""pretium""","Lorem ipsum dolor sit amet, consectetur adipiscing elit." +2022-10-27,October 27,Thursday,12:00 AM,fatigued,"some ""quote""","Suspendisse sit amet","Phaśellus pharetra justo ac dui lacinia ullamcorper." +2022-10-26,October 26,Wednesday,10:00 PM,captivated,"big social gathering | ""quoted activity""",,"Sed ut est ""interdum""","Maecenas dictum augue in nibh pellentesque porttitor." +2022-10-26,October 26,Wednesday,8:00 PM,tired,"activity with ""quotes""","Mauris rutrum ""diam""","Quisque dictum odio quis augue consectetur, at convallis żodio aliquam." diff --git a/tests/files/scenarios/partially-ok/inconsistent-fields.csv b/tests/files/scenarios/partially-ok/inconsistent-fields.csv new file mode 100644 index 0000000..4f0cf09 --- /dev/null +++ b/tests/files/scenarios/partially-ok/inconsistent-fields.csv @@ -0,0 +1,6 @@ +full_date,date,weekday,time,mood,activities,note_title,note +2022-10-30,October 30,Sunday,10:04 AM,vaguely ok,2ćities skylines | dó#lóó fa$$s_ą%,"Dolomet","Lorem ipsum sit dolomet amęt." +2022-10-27,October 27,Thursday,1:49 PM,vaguely good,chess,"Cras pretium","Lorem ipsum dolor sit amet, consectetur adipiscing elit.",extra field +2022-10-27,October 27,Thursday,12:00 AM,fatigued,allegro | working remotely,"Suspendisse sit amet" +2022-10-26,October 26,Wednesday,10:00 PM,captivated,at the office | board game | colleague interaction | big social gathering,,"Sed ut est interdum","Maecenas dictum augue in nibh pellentesque porttitor." +2022-10-26,October 26,Wednesday,8:00 PM,tired,allegro | at the office | board game | colleague interaction | big social gathering,"Mauris rutrum diam" diff --git a/tests/files/scenarios/partially-ok/missing-fields.csv b/tests/files/scenarios/partially-ok/missing-fields.csv new file mode 100644 index 0000000..1ecb9cd --- /dev/null +++ b/tests/files/scenarios/partially-ok/missing-fields.csv @@ -0,0 +1,6 @@ +full_date,date,weekday,time,mood,activities,note_title,note +2022-10-30,October 30,Sunday,10:04 AM,vaguely ok,2ćities skylines | dó#lóó fa$$s_ą%,"Dolomet","Lorem ipsum sit dolomet amęt." +,October 27,Thursday,1:49 PM,vaguely good,chess,"Cras pretium","Lorem ipsum dolor sit amet, consectetur adipiscing elit." +2022-10-27,October 27,,12:00 AM,fatigued,allegro | working remotely,"Suspendisse sit amet","Phaśellus pharetra justo ac dui lacinia ullamcorper." +2022-10-26,October 26,Wednesday,10:00 PM,,at the office | board game | colleague interaction | big social gathering,,"Sed ut est interdum", +2022-10-26,October 26,Wednesday,8:00 PM,tired,allegro | at the office | board game | colleague interaction | big social gathering,"Mauris rutrum diam","Quisque dictum odio quis augue consectetur, at convallis żodio aliquam." diff --git a/tests/files/scenarios/partially-ok/quoted-fields.csv b/tests/files/scenarios/partially-ok/quoted-fields.csv new file mode 100644 index 0000000..db7dad7 --- /dev/null +++ b/tests/files/scenarios/partially-ok/quoted-fields.csv @@ -0,0 +1,6 @@ +full_date,date,weekday,time,mood,activities,note_title,note +2022-10-30,October 30,Sunday,10:04 AM,"vaguely ok, but uncertain","2ćities skylines, dó#lóó fa$$s_ą%","Dolomet, et al.","Lorem ipsum sit dolomet amęt, consectetur adipiscing elit." +2022-10-27,October 27,Thursday,1:49 PM,vaguely good,"chess, reading","Cras pretium, sed dolor","Lorem ipsum dolor sit amet, consectetur adipiscing elit." +2022-10-27,October 27,Thursday,12:00 AM,fatigued,"allegro, working remotely","Suspendisse sit amet","Phaśellus pharetra justo ac dui lacinia ullamcorper." +2022-10-26,October 26,Wednesday,10:00 PM,captivated,"at the office, board game, colleague interaction, big social gathering","Sed ut est interdum","Maecenas dictum augue in nibh pellentesque porttitor." +2022-10-26,October 26,Wednesday,8:00 PM,tired,"allegro, at the office, board game, colleague interaction, big social gathering","Mauris rutrum diam","Quisque dictum odio quis augue consectetur, at convallis żodio aliquam." diff --git a/tests/suppress.py b/tests/suppress.py index 612c7c5..bd700f1 100644 --- a/tests/suppress.py +++ b/tests/suppress.py @@ -17,9 +17,9 @@ def __exit__(self, exc_type, exc_val, exc_tb): def out(func): def wrapper(*a, **ka): - with open(os.devnull, 'w') as devnull,\ - contextlib.redirect_stdout(devnull),\ - contextlib.redirect_stderr(devnull),\ + with open(os.devnull, 'w') as devnull, \ + contextlib.redirect_stdout(devnull), \ + contextlib.redirect_stderr(devnull), \ DisableLogging(): return func(*a, **ka) diff --git a/tests/test_dated_entries_group.py b/tests/test_dated_entries_group.py deleted file mode 100644 index acca492..0000000 --- a/tests/test_dated_entries_group.py +++ /dev/null @@ -1,184 +0,0 @@ -from unittest import TestCase, skip - -import tests.suppress as suppress -from daylio_to_md.dated_entries_group import \ - DatedEntriesGroup, \ - InvalidDateError, \ - DatedEntryMissingError, \ - TriedCreatingDuplicateDatedEntryError, \ - IncompleteDataRow - - -class TestDate(TestCase): - def setUp(self): - # Create a sample date - self.sample_date = DatedEntriesGroup("2011-10-10") - # Append two sample entries to that day - self.sample_date.create_dated_entry_from_row( - { - "time": "10:00 AM", - "mood": "vaguely ok", - "activities": "", - "note_title": "", - "note": "" - } - ) - self.sample_date.create_dated_entry_from_row( - { - "time": "9:30 PM", - "mood": "awful", - "activities": "", - "note_title": "", - "note": "" - } - ) - - @suppress.out - def test_creating_entries_from_row(self): - """ - Test whether you can successfully create :class:`DatedEntry` objects from this builder class. - """ - my_date = DatedEntriesGroup("1999-05-07") - my_date.create_dated_entry_from_row( - { - "time": "10:00 AM", - "mood": "vaguely ok", - "activities": "", - "note_title": "", - "note": "" - } - ) - # This would be a duplicate from the one already created - with self.assertRaises(TriedCreatingDuplicateDatedEntryError): - my_date.create_dated_entry_from_row( - { - "time": "10:00 AM", - "mood": "vaguely ok", - "activities": "", - "note_title": "", - "note": "" - } - ) - # This lacks the minimum required keys - time and mood - to function correctly - with self.assertRaises(IncompleteDataRow): - my_date.create_dated_entry_from_row( - { - "time": "5:00 PM", - "mood": "", - "activities": "", - "note_title": "", - "note": "" - } - ) - - @suppress.out - def test_create_dated_entries_groups(self): - """ - Try to instantiate an object of :class:`DatedEntriesGroup` with either valid or invalid dates - """ - self.assertEqual("2023-10-15", str(DatedEntriesGroup("2023-10-15"))) - self.assertEqual("2019-5-9", str(DatedEntriesGroup("2019-5-9"))) - self.assertEqual("2023-11-25", str(DatedEntriesGroup("2023-11-25"))) - - self.assertRaises(InvalidDateError, DatedEntriesGroup, "00-") - self.assertRaises(InvalidDateError, DatedEntriesGroup, "2199-32-32") - - # Test cases with unconventional date formats - self.assertRaises(InvalidDateError, DatedEntriesGroup, "2022/05/18") # Invalid separator - self.assertRaises(InvalidDateError, DatedEntriesGroup, "2023_07_12") # Invalid separator - self.assertRaises(InvalidDateError, DatedEntriesGroup, "1999.10.25") # Invalid separator - - # Test cases with random characters in the date string - self.assertRaises(InvalidDateError, DatedEntriesGroup, "2@#0$2-05-18") # Special characters in the year - self.assertRaises(InvalidDateError, DatedEntriesGroup, "1987-0%4-12") # Special characters in the month - self.assertRaises(InvalidDateError, DatedEntriesGroup, "2001-07-3*") # Special characters in the day - - # Test cases with excessive spaces - self.assertRaises(InvalidDateError, DatedEntriesGroup, " 2022-05-18 ") # Spaces around the date - self.assertRaises(InvalidDateError, DatedEntriesGroup, "1999- 10-25") # Space after the month - self.assertRaises(InvalidDateError, DatedEntriesGroup, " 2000-04 - 12 ") # Spaces within the date - - # Test cases with mixed characters and numbers - self.assertRaises(InvalidDateError, DatedEntriesGroup, "2k20-05-18") # Non-numeric characters in the year - self.assertRaises(InvalidDateError, DatedEntriesGroup, "1999-0ne-25") # Non-numeric characters in the month - self.assertRaises(InvalidDateError, DatedEntriesGroup, "2021-07-Two") # Non-numeric characters in the day - - # Test cases with missing parts of the date - self.assertRaises(InvalidDateError, DatedEntriesGroup, "2022-05") # Missing day - self.assertRaises(InvalidDateError, DatedEntriesGroup, "1987-09") # Missing day - self.assertRaises(InvalidDateError, DatedEntriesGroup, "2001") # Missing month and day - self.assertRaises(InvalidDateError, DatedEntriesGroup, "") # Empty string - - @suppress.out - def test_access_dated_entry(self): - """ - Uses the :class:`DatedEntryGroup` object from :func:`setUp` with sample entries. - Tries to either access existing entries through :func:`access_dated_entry` or missing ones. - Expected behaviour is for the :class:`DatedEntryGroup` to return the entry object if exists or raise exception. - """ - self.assertEqual("10:00 AM", str(self.sample_date.access_dated_entry("10:00 AM"))) - self.assertEqual("9:30 PM", str(self.sample_date.access_dated_entry("9:30 PM"))) - - # Test cases for 12-hour format - self.assertRaises(DatedEntryMissingError, self.sample_date.access_dated_entry, "2: AM") # <- no minutes - self.assertRaises(DatedEntryMissingError, self.sample_date.access_dated_entry, "15:45 PM") # <- above 12h - self.assertRaises(DatedEntryMissingError, self.sample_date.access_dated_entry, "14:45 PM") # <- above 12h - # noinspection SpellCheckingInspection - self.assertRaises(DatedEntryMissingError, self.sample_date.access_dated_entry, "11:30 XM") # <- wrong meridiem - # noinspection SpellCheckingInspection - self.assertRaises(DatedEntryMissingError, self.sample_date.access_dated_entry, "03:20 XM") # <- wrong meridiem - - # Test cases for 24-hour format - self.assertRaises(DatedEntryMissingError, self.sample_date.access_dated_entry, "25:15") # <- above 24h - self.assertRaises(DatedEntryMissingError, self.sample_date.access_dated_entry, "11:78") # <- above 59m - - # Test cases with invalid characters - # noinspection SpellCheckingInspection - self.assertRaises(DatedEntryMissingError, self.sample_date.access_dated_entry, "/ASDFVDJU\\") - - # Other test cases with incomplete time information - self.assertRaises(DatedEntryMissingError, self.sample_date.access_dated_entry, "2022-1") - self.assertRaises(DatedEntryMissingError, self.sample_date.access_dated_entry, "12:") - self.assertRaises(DatedEntryMissingError, self.sample_date.access_dated_entry, ":30") - - @suppress.out - def test_get_known_dated_entries(self): - """ - Difference between access_dated_entry(time) and get_known_dated_entries[time]: - - former will create missing entries, if time is valid - - latter will raise KeyError if the entry is missing, even if time is valid - - former will raise ValueError if time is invalid - - latter will raise KeyError if time is invalid - """ - self.assertEqual("9:30 PM", str(self.sample_date.known_entries_from_this_day["9:30 PM"])) - self.assertEqual("10:00 AM", str(self.sample_date.known_entries_from_this_day["10:00 AM"])) - - self.assertRaises(KeyError, lambda: self.sample_date.known_entries_from_this_day["23:00"]) - self.assertRaises(KeyError, lambda: self.sample_date.known_entries_from_this_day["11:50 AM"]) - - @suppress.out - def test_truthiness_of_dated_entries_group(self): - """ - DatedEntriesGroup should be truthy if it has a valid UID and has any known entries. - """ - self.assertGreater(len(self.sample_date.known_entries_from_this_day), 0) - - @suppress.out - def test_falseness_of_dated_entries_group(self): - """ - DatedEntriesGroup should be falsy if it has a valid UID but no known entries. - """ - another_day = DatedEntriesGroup("2019-09-12") - self.assertEqual(len(another_day.known_entries_from_this_day), 0) - self.assertFalse(another_day.known_entries_from_this_day) - - @skip("not yet implemented") - def test_no_duplicate_entries_created(self): - """ - DatedEntriesGroup should return the already existing entry if it is known, instead of creating a duplicate. - """ - self.assertEqual(True, True) - - @skip("not yet implemented") - def test_retrieve_known_entries(self): - self.assertEqual(True, True) diff --git a/tests/test_dated_entry.py b/tests/test_dated_entry.py deleted file mode 100644 index 00046d2..0000000 --- a/tests/test_dated_entry.py +++ /dev/null @@ -1,197 +0,0 @@ -from unittest import TestCase - -import tests.suppress as suppress -from daylio_to_md.config import options -from daylio_to_md.dated_entry import \ - Time, \ - DatedEntry, \ - IsNotTimeError - - -class TestDatedEntryUtils(TestCase): - @suppress.out - def test_is_time_format_valid(self): - self.assertTrue(Time.is_format_valid("1:49 AM")) - self.assertTrue(Time.is_format_valid("02:15")) - self.assertTrue(Time.is_format_valid("12:00")) - self.assertTrue(Time.is_format_valid("1:49 PM")) - self.assertFalse(Time.is_format_valid("1::49")) - self.assertFalse(Time.is_format_valid("12:60 AM")) - # noinspection SpellCheckingInspection - self.assertFalse(Time.is_format_valid("okk:oksdf s")) - self.assertFalse(Time.is_format_valid("25:00 AM")) - self.assertFalse(Time.is_format_valid("26:10")) - self.assertFalse(Time.is_format_valid("12:60 PM")) - self.assertFalse(Time.is_format_valid("12:00 XX")) - self.assertFalse(Time.is_format_valid("abc:def AM")) - self.assertFalse(Time.is_format_valid("abc:def XM")) - self.assertFalse(Time.is_format_valid("24:00 PM")) - self.assertFalse(Time.is_format_valid("00:61 AM")) - self.assertFalse(Time.is_format_valid("---")) - self.assertFalse(Time.is_format_valid("23y7vg")) - self.assertFalse(Time.is_format_valid("::::")) - self.assertFalse(Time.is_format_valid("????")) - self.assertFalse(Time.is_format_valid("00000:000000000000")) - self.assertFalse(Time.is_format_valid("99:12")) - self.assertFalse(Time.is_format_valid("11:12 UU")) - self.assertFalse(Time.is_format_valid("9::12")) - - # as expected, this will return True, because we're not checking ranges yet - self.assertTrue(Time.is_format_valid("14:59 AM")) - - @suppress.out - def test_is_time_range_valid(self): - self.assertTrue(Time.is_range_valid("11:00 AM")) - self.assertTrue(Time.is_range_valid("3:00 AM")) - self.assertTrue(Time.is_range_valid("7:59 AM")) - self.assertTrue(Time.is_range_valid("17:50")) - self.assertTrue(Time.is_range_valid("21:37")) - self.assertTrue(Time.is_range_valid("00:00")) - self.assertTrue(Time.is_range_valid("14:25")) - - self.assertFalse(Time.is_range_valid("31:00")) - self.assertFalse(Time.is_range_valid("11:79")) - self.assertFalse(Time.is_range_valid("20:99 PM")) - self.assertFalse(Time.is_range_valid("-5:12")) - self.assertFalse(Time.is_range_valid("-5:-12")) - self.assertFalse(Time.is_range_valid("-5:-12")) - self.assertFalse(Time.is_range_valid("13:00 AM")) - self.assertFalse(Time.is_range_valid("15:00 PM")) - - -class TestTime(TestCase): - @suppress.out - def test_try_creating_valid_times(self): - # Valid time formats - self.assertTrue(Time("1:49 AM")) - self.assertTrue(Time("02:15 AM")) - self.assertTrue(Time("12:00 PM")) - self.assertTrue(Time("6:30 PM")) - self.assertTrue(Time("9:45 PM")) - self.assertTrue(Time("00:00 AM")) - self.assertTrue(Time("12:00 AM")) - self.assertTrue(Time("13:30")) - self.assertTrue(Time("9:45")) - - @suppress.out - def test_try_whitespaces(self): - self.assertTrue(Time(" 1:49 AM ")) - self.assertTrue(Time("02:15 AM ")) - self.assertTrue(Time(" 12:00 PM")) - # Leading 0 or not is consistent which what was passed, not with what the function thinks is best - self.assertEqual("1:49 AM", str(Time(" 1:49 AM "))) - self.assertEqual("02:15 AM", str(Time("02:15 AM "))) - self.assertEqual("12:00 PM", str(Time(" 12:00 PM"))) - - @suppress.out - def test_try_creating_invalid_times(self): - # Invalid time formats - # noinspection SpellCheckingInspection - self.assertRaises(IsNotTimeError, Time, "okk:oksdf s") - self.assertRaises(IsNotTimeError, Time, "14:59 AM") - self.assertRaises(IsNotTimeError, Time, "25:00 AM") - self.assertRaises(IsNotTimeError, Time, "26:10") - self.assertRaises(IsNotTimeError, Time, "12:60 PM") - self.assertRaises(IsNotTimeError, Time, "12:00 XX") - self.assertRaises(IsNotTimeError, Time, "abc:def AM") - self.assertRaises(IsNotTimeError, Time, "24:00 PM") - self.assertRaises(IsNotTimeError, Time, "00:61 AM") - - @suppress.out - def test_str(self): - self.assertEqual("1:49 AM", str(Time("1:49 AM"))) - self.assertEqual("02:15 AM", str(Time("02:15 AM"))) - self.assertEqual("12:00 PM", str(Time("12:00 PM"))) - self.assertEqual("6:30 PM", str(Time("6:30 PM"))) - self.assertEqual("9:45 PM", str(Time("9:45 PM"))) - self.assertEqual("00:00 AM", str(Time("00:00 AM"))) - self.assertEqual("12:00 AM", str(Time("12:00 AM"))) - self.assertEqual("13:30", str(Time("13:30"))) - self.assertEqual("9:45", str(Time("9:45"))) - - -class TestDatedEntry(TestCase): - @suppress.out - def test_bare_minimum_dated_entries(self): - # When - bare_minimum_dated_entry = DatedEntry( - time="1:49 AM", - mood="vaguely ok" - ) - - # Then - self.assertEqual("vaguely ok", bare_minimum_dated_entry.mood) - self.assertEqual("1:49 AM", str(bare_minimum_dated_entry.uid)) - self.assertIsNone(bare_minimum_dated_entry.title) - self.assertIsNone(bare_minimum_dated_entry.note) - self.assertListEqual([], bare_minimum_dated_entry.activities) - - @suppress.out - def test_other_variants_of_dated_entries(self): - # When - entry = DatedEntry( - time="1:49 AM", - mood="vaguely ok", - title="Normal situation" - ) - - # Then - self.assertEqual("vaguely ok", entry.mood) - self.assertEqual("1:49 AM", str(entry.uid)) - self.assertEqual("Normal situation", entry.title) - self.assertIsNone(entry.note) - self.assertListEqual([], entry.activities) - - # When - entry = DatedEntry( - time="1:49 AM", - mood="vaguely ok", - title="Normal situation", - note="A completely normal situation just occurred." - ) - - # Then - self.assertEqual("vaguely ok", entry.mood) - self.assertEqual("1:49 AM", str(entry.uid)) - self.assertEqual("Normal situation", entry.title) - self.assertEqual("A completely normal situation just occurred.", entry.note) - self.assertListEqual([], entry.activities) - - # When - options.tag_activities = True - entry = DatedEntry( - time="1:49 AM", - mood="vaguely ok", - title="Normal situation", - note="A completely normal situation just occurred.", - activities="bicycle|chess|gaming" - ) - - # Then - self.assertEqual("vaguely ok", entry.mood) - self.assertEqual("1:49 AM", str(entry.uid)) - self.assertEqual("Normal situation", entry.title) - self.assertEqual("A completely normal situation just occurred.", entry.note) - self.assertListEqual(["#bicycle", "#chess", "#gaming"], entry.activities) - - # When - options.tag_activities = False - entry = DatedEntry( - time="1:49 AM", - mood="vaguely ok", - title="Normal situation", - note="A completely normal situation just occurred.", - activities="bicycle|chess|gaming" - ) - - # Then - self.assertEqual("vaguely ok", entry.mood) - self.assertEqual("1:49 AM", str(entry.uid)) - self.assertEqual("Normal situation", entry.title) - self.assertEqual("A completely normal situation just occurred.", entry.note) - self.assertListEqual(["bicycle", "chess", "gaming"], entry.activities) - - @suppress.out - def test_insufficient_dated_entries(self): - self.assertRaises(ValueError, DatedEntry, time="2:00", mood="") - self.assertRaises(ValueError, DatedEntry, time=":00", mood="vaguely ok") diff --git a/tests/test_group.py b/tests/test_group.py new file mode 100644 index 0000000..5fb7d76 --- /dev/null +++ b/tests/test_group.py @@ -0,0 +1,190 @@ +import datetime +from unittest import TestCase + +import tests.suppress as suppress +from daylio_to_md.group import \ + EntriesFrom, \ + EntryMissingError, \ + IncompleteDataRow +from daylio_to_md.utils import InvalidDateError, InvalidTimeError + + +class TestDate(TestCase): + @suppress.out + def setUp(self): + # Create a sample date + self.sample_date = EntriesFrom("2011-10-10") + # Append two sample entries to that day + self.sample_date.create_entry( + { + "time": "10:00 AM", + "mood": "vaguely ok", + "activities": "", + "note_title": "", + "note": "" + } + ) + self.sample_date.create_entry( + { + "time": "9:30 PM", + "mood": "awful", + "activities": "", + "note_title": "", + "note": "" + } + ) + + @suppress.out + def test_creating_duplicates_which_are_allowed_in_daylio(self): + # TODO: actually test this + self.sample_date.create_entry( + { + "time": "10:00 AM", + "mood": "vaguely ok", + "activities": "", + "note_title": "", + "note": "" + } + ) + + @suppress.out + def test_creating_entries_from_row(self): + """ + Test whether you can successfully create :class:`Entry` objects from this builder class. + """ + my_date = EntriesFrom("1999-05-07") + my_date.create_entry( + { + "time": "10:00 AM", + "mood": "vaguely ok", + "activities": "", + "note_title": "", + "note": "" + } + ) + # This lacks the minimum required keys - time and mood - to function correctly + with self.assertRaises(IncompleteDataRow): + my_date.create_entry( + { + "time": "5:00 PM", + "mood": "", + "activities": "", + "note_title": "", + "note": "" + } + ) + + @suppress.out + def test_create_entry_groups(self): + """ + Try to instantiate an object of :class:`DatedEntriesGroup` with either valid or invalid dates + """ + # str() function converts the object's uid, which in this case is a datetime.date object. + self.assertEqual("2023-10-15", str(EntriesFrom("2023-10-15"))) + self.assertEqual("2019-05-09", str(EntriesFrom("2019-5-9"))) + self.assertEqual("2023-11-25", str(EntriesFrom("2023-11-25"))) + # direct comparison with a datetime.date object should on comparing only their dates + self.assertEqual( + datetime.date(2023, 10, 15), + EntriesFrom("2023-10-15").date) + self.assertEqual( + datetime.date(2022, 5, 18), + EntriesFrom(" 2022-05-18 ").date) # Spaces around the date + + self.assertRaises(InvalidDateError, EntriesFrom, "00-") + self.assertRaises(InvalidDateError, EntriesFrom, "2199-32-32") + + # Test cases with unconventional date formats + self.assertRaises(InvalidDateError, EntriesFrom, "2022/05/18") # Invalid separator + self.assertRaises(InvalidDateError, EntriesFrom, "2023_07_12") # Invalid separator + self.assertRaises(InvalidDateError, EntriesFrom, "1999.10.25") # Invalid separator + + # Test cases with random characters in the date string + self.assertRaises(InvalidDateError, EntriesFrom, "2@#0$2-05-18") # Special characters in the year + self.assertRaises(InvalidDateError, EntriesFrom, "1987-0%4-12") # Special characters in the month + self.assertRaises(InvalidDateError, EntriesFrom, "2001-07-3*") # Special characters in the day + + # Test cases with excessive spaces + self.assertRaises(InvalidDateError, EntriesFrom, "1999- 10-25") # Spaces within the date + self.assertRaises(InvalidDateError, EntriesFrom, " 2000-04 - 12 ") # Spaces within the date + + # Test cases with mixed characters and numbers + self.assertRaises(InvalidDateError, EntriesFrom, "2k20-05-18") # Non-numeric characters in the year + self.assertRaises(InvalidDateError, EntriesFrom, "1999-0ne-25") # Non-numeric characters in the month + self.assertRaises(InvalidDateError, EntriesFrom, "2021-07-Two") # Non-numeric characters in the day + + # Test cases with missing parts of the date + self.assertRaises(InvalidDateError, EntriesFrom, "2022-05") # Missing day + self.assertRaises(InvalidDateError, EntriesFrom, "1987-09") # Missing day + self.assertRaises(InvalidDateError, EntriesFrom, "2001") # Missing month and day + self.assertRaises(InvalidDateError, EntriesFrom, "") # Empty string + + # noinspection PyStatementEffect,SpellCheckingInspection + @suppress.out + def test_access_dated_entry(self): + self.assertEqual("21:30", str(self.sample_date["9:30PM"])) + self.assertEqual(datetime.time(10, 0), self.sample_date["10:00"].time) + + # Test cases for 12-hour format + with self.assertRaises(InvalidTimeError): + self.sample_date["2: AM"] # <- no minutes + + with self.assertRaises(InvalidTimeError): + self.sample_date["15:45 PM"] # <- above 12h + + with self.assertRaises(InvalidTimeError): + self.sample_date["14:45 PM"] # <- above 12h + + # noinspection SpellCheckingInspection + with self.assertRaises(InvalidTimeError): + self.sample_date["11:30 XM"] # <- wrong meridiem + + # noinspection SpellCheckingInspection + with self.assertRaises(InvalidTimeError): + self.sample_date["03:20 XM"] # <- wrong meridiem + + # Test cases for 24-hour format + with self.assertRaises(InvalidTimeError): + self.sample_date["25:15"] # <- above 24h + + with self.assertRaises(InvalidTimeError): + self.sample_date["11:78"] # <- above 59m + + # Test cases with invalid characters + with self.assertRaises(InvalidTimeError): + self.sample_date["/ASDFVDJU\\"] + + # Other test cases with incomplete time information + with self.assertRaises(InvalidTimeError): + self.sample_date["2022-1"] + + with self.assertRaises(InvalidTimeError): + self.sample_date["12:"] + + with self.assertRaises(InvalidTimeError): + self.sample_date[":30"] + + @suppress.out + def test_get_known_dated_entries(self): + self.assertEqual("21:30", str(self.sample_date["9:30 PM"])) + self.assertEqual("10:00", str(self.sample_date["10:00 AM"])) + + # Either Exception should work because the EntryMissingError is a subclass of KeyError + self.assertRaises(KeyError, lambda: self.sample_date["23:00"]) + self.assertRaises(EntryMissingError, lambda: self.sample_date["11:50 AM"]) + + @suppress.out + def test_truthiness_of_dated_entries_group(self): + """ + DatedEntriesGroup should be truthy if it has a valid UID and has any known entries. + """ + self.assertGreater(len(self.sample_date.known_entries), 0) + + @suppress.out + def test_falseness_of_dated_entries_group(self): + """ + DatedEntriesGroup should be falsy if it has a valid UID but no known entries. + """ + another_day = EntriesFrom("2019-09-12") + self.assertEqual(len(another_day.known_entries), 0) + self.assertFalse(another_day.known_entries) diff --git a/tests/test_journal_entry.py b/tests/test_journal_entry.py new file mode 100644 index 0000000..4b12a00 --- /dev/null +++ b/tests/test_journal_entry.py @@ -0,0 +1,116 @@ +import datetime +from unittest import TestCase + +import tests.suppress as suppress +from daylio_to_md.utils import InvalidTimeError +from daylio_to_md.journal_entry import \ + Entry, \ + BaseEntryConfig, \ + NoMoodError + + +class TestJournalEntry(TestCase): + @suppress.out + def test_bare_minimum_journal_entries(self): + # When + bare_minimum_entry = Entry( + time="1:49 AM", + mood="vaguely ok" + ) + + # Then + self.assertEqual("vaguely ok", bare_minimum_entry.mood) + self.assertEqual(datetime.time(1, 49), bare_minimum_entry.time) + self.assertIsNone(bare_minimum_entry.title) + self.assertIsNone(bare_minimum_entry.note) + self.assertListEqual([], bare_minimum_entry.activities) + + @suppress.out + def test_other_variants_of_journal_entries(self): + # When + entry = Entry( + time="1:49 AM", + mood="vaguely ok", + title="Normal situation" + ) + + # Then + self.assertEqual("vaguely ok", entry.mood) + self.assertEqual(datetime.time(1, 49), entry.time) + self.assertEqual("Normal situation", entry.title) + self.assertIsNone(entry.note) + self.assertListEqual([], entry.activities) + + # When + entry = Entry( + time="1:49 AM", + mood="vaguely ok", + title="Normal situation", + note="A completely normal situation just occurred." + ) + + # Then + self.assertEqual("vaguely ok", entry.mood) + self.assertEqual(datetime.time(1, 49), entry.time) + self.assertEqual("Normal situation", entry.title) + self.assertEqual("A completely normal situation just occurred.", entry.note) + self.assertListEqual([], entry.activities) + + # When + tag_my_activities = BaseEntryConfig(tag_activities=True) + entry = Entry( + time="1:49 AM", + mood="vaguely ok", + title="Normal situation", + note="A completely normal situation just occurred.", + activities="bicycle|chess|gaming", + config=tag_my_activities + ) + + # Then + self.assertEqual("vaguely ok", entry.mood) + self.assertEqual(datetime.time(1, 49), entry.time) + self.assertEqual("Normal situation", entry.title) + self.assertEqual("A completely normal situation just occurred.", entry.note) + self.assertListEqual(["#bicycle", "#chess", "#gaming"], entry.activities) + + # When + do_not_tag_my_activities = BaseEntryConfig(tag_activities=False) + entry = Entry( + time="3:49 PM", + mood="vaguely ok", + title="Normal situation", + note="A completely normal situation just occurred.", + activities="bicycle|chess|gaming", + config=do_not_tag_my_activities + ) + + # Then + self.assertEqual("vaguely ok", entry.mood) + self.assertEqual(datetime.time(15, 49), entry.time) + self.assertEqual("Normal situation", entry.title) + self.assertEqual("A completely normal situation just occurred.", entry.note) + self.assertListEqual(["bicycle", "chess", "gaming"], entry.activities) + + @suppress.out + def test_insufficient_journal_entries(self): + self.assertRaises(NoMoodError, Entry, time="2:00", mood="") + self.assertRaises(InvalidTimeError, Entry, time=":00", mood="vaguely ok") + + @suppress.out + def test_entries_with_weird_activity_lists(self): + # When + tag_my_activities = BaseEntryConfig(tag_activities=True) + entry = Entry( + time="11:49 PM", + mood="vaguely ok", + activities="||bicycle|@Q$@$Q''\"chess|gaming-+/$@q4%#!!", + config=tag_my_activities + ) + + # Then + self.assertEqual("vaguely ok", entry.mood) + self.assertEqual(datetime.time(23, 49), entry.time) + self.assertIsNone(entry.title) + self.assertIsNone(entry.note) + self.assertListEqual(['#bicycle', '#qqchess', '#gaming-q4'], entry.activities) diff --git a/tests/test_librarian.py b/tests/test_librarian.py index 2c4ba0c..e0cbf3b 100644 --- a/tests/test_librarian.py +++ b/tests/test_librarian.py @@ -1,10 +1,9 @@ from unittest import TestCase import tests.suppress as suppress -from daylio_to_md import librarian from daylio_to_md.config import options from daylio_to_md.entry.mood import Moodverse -from daylio_to_md.librarian import Librarian +from daylio_to_md.librarian import Librarian, CannotAccessJournalError class TestLibrarian(TestCase): @@ -16,51 +15,43 @@ class TestLibrarian(TestCase): @suppress.out def test_init_valid_csv(self): - self.assertTrue(Librarian("tests/files/journal_CSVs/sheet-1-valid-data.csv")) + self.assertTrue(Librarian("tests/files/all-valid.csv")) @suppress.out def test_init_invalid_csv(self): """ Pass faulty files and see if it fails as expected. """ - self.assertRaises(librarian.CannotAccessFileError, Librarian, - "tests/files/journal_CSVs/sheet-2-corrupted-bytes.csv") - self.assertRaises(librarian.CannotAccessFileError, Librarian, - "tests/files/journal_CSVs/sheet-3-wrong-format.txt") - self.assertRaises(librarian.CannotAccessFileError, Librarian, - "tests/files/journal_CSVs/sheet-4-no-extension") - self.assertRaises(librarian.CannotAccessFileError, Librarian, - "tests/files/journal_CSVs/sheet-5-missing-file.csv") + self.assertRaises(CannotAccessJournalError, Librarian, + "tests/files/scenarios/fail/corrupted.csv") + self.assertRaises(CannotAccessJournalError, Librarian, + "tests/files/scenarios/fail/wrong-format.txt") + # TODO: what to do with noextension file? + self.assertRaises(CannotAccessJournalError, Librarian, + "tests/files/fail/missing.csv") # TODO: handle this case in Librarian - # self.assertRaises(lib.CannotAccessFileError, Librarian, "tests/files/journal_CSVs/sheet-6-empty-file.csv") + # self.assertRaises(lib.CannotAccessFileError, Librarian, "tests/files/scenarios/fail/empty.csv") # TODO: maybe generate corrupted_sheet and wrong_format during runner setup in workflow mode? # dd if=/dev/urandom of="$corrupted_file" bs=1024 count=10 # generates random bytes and writes them into a given file - # TODO: make this file locked during runner workflow with chmod 600 - self.assertRaises(librarian.CannotAccessFileError, Librarian, "tests/locked-dir/locked_file.csv") + # TODO: move check locked file test into Docker run @suppress.out def test_valid_access_dates(self): """ - All the following dates exist in the ``tests/files/journal_CSVs/sheet-1-valid-data.csv``. + All the following dates exist in the ``tests/files/all-valid.csv``. They should be accessible by ``lib``. """ # When lib = Librarian( - path_to_file="tests/files/journal_CSVs/sheet-1-valid-data.csv", - path_to_moods="moods.json" + path_to_file="tests/files/all-valid.csv", + path_to_moods="all-valid.json" ) # Then - self.assertTrue(lib.access_date("2022-10-25")) - self.assertTrue(lib.access_date("2022-10-26")) - self.assertTrue(lib.access_date("2022-10-27")) - self.assertTrue(lib.access_date("2022-10-30")) - - # Check if get-item method of accessing date groups also works self.assertTrue(lib["2022-10-25"]) self.assertTrue(lib["2022-10-26"]) self.assertTrue(lib["2022-10-27"]) @@ -69,35 +60,24 @@ def test_valid_access_dates(self): @suppress.out def test_wrong_access_dates(self): """ - **None** of the following dates exist in the ``tests/files/journal_CSVs/sheet-1-valid-data.csv``. - Therefore they should **NOT** be accessible by ``lib``. + **None** of the following dates exist in the ``tests/files/all-valid.csv``. + Therefore, they should **NOT** be accessible by ``lib``. """ # When lib = Librarian( - path_to_file="tests/files/journal_CSVs/sheet-1-valid-data.csv", - path_to_moods="moods.json" + path_to_file="tests/files/all-valid.csv", + path_to_moods="all-valid.json" ) - # Then can access valid dates, even if they weren't in the file - self.assertTrue(lib.access_date("2022-10-21")) - self.assertTrue(lib.access_date("2022-10-20")) - self.assertTrue(lib.access_date("2022-10-2")) - self.assertTrue(lib.access_date("1999-10-22")) - # this dict method should also work - self.assertTrue(lib["2005-01-19"]) - - # But once I try to access the actual entries attached to those dates, they should be empty - self.assertFalse(lib.access_date("2022-10-21").known_entries_from_this_day) - self.assertFalse(lib.access_date("2022-10-20").known_entries_from_this_day) - self.assertFalse(lib.access_date("2022-10-2").known_entries_from_this_day) - self.assertFalse(lib.access_date("2022-10-22").known_entries_from_this_day) - self.assertFalse(lib.access_date("1999-1-1").known_entries_from_this_day) + self.assertRaises(KeyError, lambda: lib["2022-10-21"]) + self.assertRaises(KeyError, lambda: lib["2022-10-20"]) + self.assertRaises(KeyError, lambda: lib["2022-10-2"]) + self.assertRaises(KeyError, lambda: lib["1999-10-22"]) # check if Librarian correctly raises ValueError when trying to check invalid dates - self.assertRaises(ValueError, lib.access_date, "ABC") - self.assertRaises(ValueError, lib.access_date, "2022") - self.assertRaises(ValueError, lib.access_date, "12:00 AM") - self.assertRaises(ValueError, lib.access_date, "1795-12-05") # year range suspicious + self.assertRaises(ValueError, lambda: lib["ABC"]) + self.assertRaises(ValueError, lambda: lib["2022"]) + self.assertRaises(ValueError, lambda: lib["12:00 AM"]) # CUSTOM AND STANDARD MOOD SETS # ----------------------------- @@ -105,32 +85,30 @@ def test_wrong_access_dates(self): def test_custom_moods_when_passed_correctly(self): """Pass a valid JSON file and see if it knows it has access to custom moods now.""" self.assertTrue(Librarian( - path_to_file="tests/files/journal_CSVs/sheet-1-valid-data.csv", - path_to_moods="moods.json" + path_to_file="tests/files/all-valid.csv", + path_to_moods="tests/files/all-valid.json" ).current_mood_set.get_custom_moods) @suppress.out def test_custom_moods_when_not_passed(self): """Pass no moods and see if it know it only has standard moods available.""" - lib = Librarian(path_to_file="tests/files/journal_CSVs/sheet-1-valid-data.csv") + lib = Librarian(path_to_file="tests/files/all-valid.csv") self.assertEqual(0, len(lib.current_mood_set.get_custom_moods), msg=lib.current_mood_set) @suppress.out def test_custom_moods_with_invalid_jsons(self): """Pass faulty moods and see if it has no custom moods loaded.""" lib = Librarian( - path_to_file="tests/files/journal_CSVs/sheet-1-valid-data.csv", - path_to_output="tests/files/output-results/", - path_to_moods="tests/files/journal_CSVs/empty_sheet.csv" + path_to_file="tests/files/all-valid.csv", + path_to_moods="tests/files/scenarios/fail/empty.csv" ) self.assertEqual(0, len(lib.current_mood_set.get_custom_moods)) @suppress.out def test_custom_moods_when_json_invalid(self): lib = Librarian( - path_to_file="tests/files/journal_CSVs/sheet-1-valid-data.csv", - path_to_output="tests/files/output-results/", - path_to_moods="tests/files/journal_CSVs/empty_sheet.csv" + path_to_file="tests/files/all-valid.csv", + path_to_moods="tests/files/scenarios/fail/empty.csv" ) default = Moodverse() self.assertDictEqual(lib.current_mood_set.get_moods, default.get_moods, @@ -140,9 +118,8 @@ def test_custom_moods_when_json_invalid(self): ]) ) lib = Librarian( - path_to_file="tests/files/journal_CSVs/sheet-1-valid-data.csv", - path_to_output="tests/files/output-results/", - path_to_moods="tests/files/journal_CSVs/empty_sheet.csv" + path_to_file="tests/files/all-valid.csv", + path_to_moods="tests/files/scenarios/fail/empty.csv" ) self.assertDictEqual(lib.current_mood_set.get_moods, default.get_moods, msg="\n".join([ @@ -150,11 +127,7 @@ def test_custom_moods_when_json_invalid(self): "default object ID:\t" + str(id(default)) ]) ) - lib = Librarian( - path_to_file="tests/files/journal_CSVs/sheet-1-valid-data.csv", - path_to_output="tests/files/output-results/", - path_to_moods="tests/files/locked-dir/locked_file.csv" - ) + # TODO: move locked folder and locked file tests into Docker run self.assertDictEqual(lib.current_mood_set.get_moods, default.get_moods, msg="\n".join([ "current ID:\t" + str(id(lib.current_mood_set)), @@ -171,9 +144,8 @@ def test_custom_moods_that_are_incomplete(self): """ options.tag_activities = True lib_to_test = Librarian( - "tests/files/journal_CSVs/sheet-1-valid-data.csv", - "tests/files/output-results/", # this argument does not take part in testing but is required - "tests/files/mood_JSONs/incomplete-moods.json" + path_to_file="tests/files/scenarios/ok/all-valid.csv", + path_to_moods="tests/files/moods/incomplete.json" ) # There are 11 moods, out of which one is a duplicate of a default mood, so 10 custom in total self.assertEqual(10, len(lib_to_test.current_mood_set.get_custom_moods), diff --git a/tests/test_mood.py b/tests/test_mood.py index 22e13c3..d628213 100644 --- a/tests/test_mood.py +++ b/tests/test_mood.py @@ -8,10 +8,6 @@ class TestMoodverse(TestCase): @suppress.out def test_default_moodverse_no_customisation(self): - self.assertDictEqual( - {"rad": "rad", "good": "good", "neutral": "neutral", "bad": "bad", "awful": "awful"}, - Moodverse().get_moods - ) self.assertFalse(Moodverse().get_custom_moods) self.assertEqual("rad", Moodverse()["rad"]) self.assertEqual("bad", Moodverse()["bad"]) @@ -93,6 +89,7 @@ def test_loading_incomplete_moodlists(self): self.assertIn("awful", my_moodverse.get_moods) self.assertIn("good", my_moodverse.get_moods) + @suppress.out def test_loading_invalid_moodlists(self): bad_moods_loaded_from_json = { "rad": ["", None], @@ -111,3 +108,10 @@ def test_loading_invalid_moodlists(self): "awful": [""] # <-- } self.assertEqual(1, len(Moodverse(bad_moods_loaded_from_json).get_custom_moods)) + + @suppress.out + def test_loading_same_moods_as_already_existing(self): + self.assertDictEqual( + {"rad": "rad", "good": "good", "neutral": "neutral", "bad": "bad", "awful": "awful"}, + Moodverse().get_moods + ) diff --git a/tests/test_output.py b/tests/test_output.py index 8db9766..52eaff4 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -5,8 +5,8 @@ import tests.suppress as suppress from daylio_to_md.config import options -from daylio_to_md.dated_entries_group import DatedEntriesGroup -from daylio_to_md.dated_entry import DatedEntry +from daylio_to_md.group import EntriesFrom, BaseFileConfig +from daylio_to_md.journal_entry import Entry, BaseEntryConfig from daylio_to_md.librarian import Librarian @@ -31,7 +31,7 @@ def test_bare_minimum_entry_content(self): # --- # Create our fake entry as well as a stream that acts like a file options.tag_activities = True - my_entry = DatedEntry(time="11:00", mood="great", activities="bicycle | chess") + my_entry = Entry(time="11:00", mood="great", activities="bicycle | chess") with io.StringIO() as my_fake_file_stream: my_entry.output(my_fake_file_stream) @@ -61,7 +61,7 @@ def test_entry_with_title_no_note(self): # --- # Create our fake entry as well as a stream that acts like a file options.tag_activities = True - my_entry = DatedEntry(time="11:00", mood="great", activities="bicycle | chess", title="I'm super pumped!") + my_entry = Entry(time="11:00", mood="great", activities="bicycle | chess", title="I'm super pumped!") with io.StringIO() as my_fake_file_stream: my_entry.output(my_fake_file_stream) @@ -91,8 +91,8 @@ def test_entry_with_title_and_note(self): # --- # Create our fake entry as well as a stream that acts like a file options.tag_activities = True - my_entry = DatedEntry(time="11:00", mood="great", activities="bicycle | chess", title="I'm super pumped!", - note="I believe I can fly, I believe I can touch the sky.") + my_entry = Entry(time="11:00", mood="great", activities="bicycle | chess", title="I'm super pumped!", + note="I believe I can fly, I believe I can touch the sky.") with io.StringIO() as my_fake_file_stream: my_entry.output(my_fake_file_stream) @@ -120,8 +120,7 @@ def test_entry_with_hashtagged_activities(self): # WHEN # --- # Create our fake entry as well as a stream that acts like a file - options.tag_activities = True - my_entry = DatedEntry(time="11:00", mood="great", activities="bicycle | chess") + my_entry = Entry(time="11:00", mood="great", activities="bicycle | chess") with io.StringIO() as my_fake_file_stream: my_entry.output(my_fake_file_stream) @@ -138,9 +137,10 @@ def test_entry_with_hashtagged_activities(self): # WHEN # --- + # Set up the config + do_not_tag_my_activities = BaseEntryConfig(tag_activities=False) # Create our fake entry as well as a stream that acts like a file - options.tag_activities = False - my_entry = DatedEntry(time="11:00", mood="great", activities="bicycle | chess") + my_entry = Entry(time="11:00", mood="great", activities="bicycle | chess", config=do_not_tag_my_activities) with io.StringIO() as my_fake_file_stream: my_entry.output(my_fake_file_stream) @@ -155,6 +155,27 @@ def test_entry_with_hashtagged_activities(self): # --- self.assertEqual(compare_stream.getvalue(), my_fake_file_stream.getvalue()) + @suppress.out + def test_header_multiplier(self): + # WHEN + # --- + # Set up the config + header_lvl_5 = BaseEntryConfig(header_multiplier=5) + # Create our fake entry as well as a stream that acts like a file + my_entry = Entry(time="11:00", mood="great", title="Feeling pumped@!", config=header_lvl_5) + + with io.StringIO() as my_fake_file_stream: + my_entry.output(my_fake_file_stream) + # AND + # --- + # Then create another stream and fill it with the same content, but written directly, not through object + with io.StringIO() as compare_stream: + compare_stream.write("##### great | 11:00 | Feeling pumped@!") + + # THEN + # --- + self.assertEqual(compare_stream.getvalue(), my_fake_file_stream.getvalue()) + class TestDatedEntriesGroup(TestCase): @suppress.out @@ -165,11 +186,12 @@ def test_outputting_day_with_one_entry(self): # WHEN # --- # Create a sample date - sample_date = DatedEntriesGroup("2011-10-10") - sample_date.append_to_known(DatedEntry( + sample_date = EntriesFrom("2011-10-10") + entry_one = Entry( time="10:00 AM", mood="vaguely ok" - )) + ) + sample_date.add(entry_one) with io.StringIO() as my_fake_file_stream: sample_date.output(my_fake_file_stream) @@ -178,10 +200,10 @@ def test_outputting_day_with_one_entry(self): # Then create another stream and fill it with the same content, but written directly, not through object with io.StringIO() as compare_stream: compare_stream.write("---" + "\n") - compare_stream.write("tags: daily" + "\n") - compare_stream.write("---" + "\n"*2) + compare_stream.write("tags: daylio" + "\n") + compare_stream.write("---" + "\n" * 2) - compare_stream.write("## vaguely ok | 10:00 AM" + "\n"*2) + compare_stream.write("## vaguely ok | 10:00" + "\n" * 2) # THEN # --- @@ -195,18 +217,19 @@ def test_outputting_day_with_two_entries(self): # WHEN # --- # Create a sample date - sample_date = DatedEntriesGroup("2011-10-10") - sample_date.append_to_known(DatedEntry( + sample_date = EntriesFrom("2011-10-10") + entry_one = Entry( time="10:00 AM", mood="vaguely ok", activities="bowling", note="Feeling kinda ok." - )) - sample_date.append_to_known(DatedEntry( + ) + entry_two = Entry( time="9:30 PM", mood="awful", title="Everything is going downhill for me" - )) + ) + sample_date.add(entry_one, entry_two) with io.StringIO() as my_fake_file_stream: sample_date.output(my_fake_file_stream) @@ -215,14 +238,14 @@ def test_outputting_day_with_two_entries(self): # Then create another stream and fill it with the same content, but written directly, not through object with io.StringIO() as compare_stream: compare_stream.write("---" + "\n") - compare_stream.write("tags: daily" + "\n") - compare_stream.write("---" + "\n"*2) + compare_stream.write("tags: daylio" + "\n") + compare_stream.write("---" + "\n" * 2) - compare_stream.write("## vaguely ok | 10:00 AM" + "\n") + compare_stream.write("## vaguely ok | 10:00" + "\n") compare_stream.write("#bowling" + "\n") - compare_stream.write("Feeling kinda ok." + "\n"*2) + compare_stream.write("Feeling kinda ok." + "\n" * 2) - compare_stream.write("## awful | 9:30 PM | Everything is going downhill for me" + "\n"*2) + compare_stream.write("## awful | 21:30 | Everything is going downhill for me" + "\n" * 2) # THEN # --- @@ -237,21 +260,22 @@ def test_outputting_day_with_two_entries_and_invalid_filetags(self): """ # WHEN # --- + # Mess up user-configured file tags + my_config_with_empty_tags = BaseFileConfig(front_matter_tags=["", None]) # Create a sample date - sample_date = DatedEntriesGroup("2011-10-10") - sample_date.append_to_known(DatedEntry( + sample_date = EntriesFrom("2011-10-10", config=my_config_with_empty_tags) + entry_one = Entry( time="10:00 AM", mood="vaguely ok", activities="bowling", note="Feeling kinda meh." - )) - sample_date.append_to_known(DatedEntry( + ) + entry_two = Entry( time="9:30 PM", mood="awful", title="Everything is going downhill for me" - )) - # Mess up user-configured file tags - options.tags = ["", None] + ) + sample_date.add(entry_one, entry_two) with io.StringIO() as my_fake_file_stream: sample_date.output(my_fake_file_stream) @@ -259,11 +283,11 @@ def test_outputting_day_with_two_entries_and_invalid_filetags(self): # --- # Then create another stream and fill it with the same content, but written directly, not through object with io.StringIO() as compare_stream: - compare_stream.write("## vaguely ok | 10:00 AM" + "\n") + compare_stream.write("## vaguely ok | 10:00" + "\n") compare_stream.write("#bowling" + "\n") - compare_stream.write("Feeling kinda meh." + "\n"*2) + compare_stream.write("Feeling kinda meh." + "\n" * 2) - compare_stream.write("## awful | 9:30 PM | Everything is going downhill for me" + "\n"*2) + compare_stream.write("## awful | 21:30 | Everything is going downhill for me" + "\n" * 2) # THEN # --- @@ -279,20 +303,21 @@ def test_outputting_day_with_two_entries_and_partially_valid_filetags(self): # WHEN # --- # Create a sample date - sample_date = DatedEntriesGroup("2011-10-10") - sample_date.append_to_known(DatedEntry( + # Mess up user-configured file tags + my_file_config = BaseFileConfig(front_matter_tags=["", "foo", "bar", None]) + sample_date = EntriesFrom("2011-10-10", config=my_file_config) + entry_one = Entry( time="10:00 AM", mood="vaguely ok", activities="bowling", note="Feeling fine, I guess." - )) - sample_date.append_to_known(DatedEntry( + ) + entry_two = Entry( time="9:30 PM", mood="awful", title="Everything is going downhill for me" - )) - # Mess up user-configured file tags - options.tags = ["", "foo", "bar", None] + ) + sample_date.add(entry_one, entry_two) with io.StringIO() as my_fake_file_stream: sample_date.output(my_fake_file_stream) @@ -302,13 +327,13 @@ def test_outputting_day_with_two_entries_and_partially_valid_filetags(self): with io.StringIO() as compare_stream: compare_stream.write("---" + "\n") compare_stream.write("tags: bar,foo" + "\n") - compare_stream.write("---" + "\n"*2) + compare_stream.write("---" + "\n" * 2) - compare_stream.write("## vaguely ok | 10:00 AM" + "\n") + compare_stream.write("## vaguely ok | 10:00" + "\n") compare_stream.write("#bowling" + "\n") - compare_stream.write("Feeling fine, I guess." + "\n"*2) + compare_stream.write("Feeling fine, I guess." + "\n" * 2) - compare_stream.write("## awful | 9:30 PM | Everything is going downhill for me" + "\n"*2) + compare_stream.write("## awful | 21:30 | Everything is going downhill for me" + "\n" * 2) # THEN # --- @@ -326,29 +351,28 @@ def test_directory_loop(self): """ Loops through known dates and asks each :class:`DatedEntriesGroup` to output its contents to a specified file. """ - options.tags = ["daily"] - lib = Librarian("tests/files/journal_CSVs/sheet-1-valid-data.csv", path_to_output="tests/files/output-results") + lib = Librarian("tests/files/all-valid.csv", path_to_output="tests/files/scenarios/ok/out") lib.output_all() - with open("tests/files/output-results/2022/10/2022-10-25.md", encoding="UTF-8") as parsed_result: - with open("tests/files/expected_results/2022-10-25.md", encoding="UTF-8") as expected_result: - self.assertListEqual(expected_result.readlines(), parsed_result.readlines()) + with open("tests/files/scenarios/ok/expect/2022-10-25.md", encoding="UTF-8") as parsed_result, \ + open("tests/files/scenarios/ok/expect/2022-10-25.md", encoding="UTF-8") as expected_result: + self.assertListEqual(expected_result.readlines(), parsed_result.readlines()) - with open("tests/files/output-results/2022/10/2022-10-26.md", encoding="UTF-8") as parsed_result: - with open("tests/files/expected_results/2022-10-26.md", encoding="UTF-8") as expected_result: - self.assertListEqual(expected_result.readlines(), parsed_result.readlines()) + with open("tests/files/scenarios/ok/expect//2022-10-26.md", encoding="UTF-8") as parsed_result, \ + open("tests/files/scenarios/ok/expect/2022-10-26.md", encoding="UTF-8") as expected_result: + self.assertListEqual(expected_result.readlines(), parsed_result.readlines()) - with open("tests/files/output-results/2022/10/2022-10-27.md", encoding="UTF-8") as parsed_result: - with open("tests/files/expected_results/2022-10-27.md", encoding="UTF-8") as expected_result: - self.assertListEqual(expected_result.readlines(), parsed_result.readlines()) + with open("tests/files/scenarios/ok/expect/2022-10-27.md", encoding="UTF-8") as parsed_result, \ + open("tests/files/scenarios/ok/expect/2022-10-27.md", encoding="UTF-8") as expected_result: + self.assertListEqual(expected_result.readlines(), parsed_result.readlines()) - with open("tests/files/output-results/2022/10/2022-10-30.md", encoding="UTF-8") as parsed_result: - with open("tests/files/expected_results/2022-10-30.md", encoding="UTF-8") as expected_result: - self.assertListEqual(expected_result.readlines(), parsed_result.readlines()) + with open("tests/files/scenarios/ok/expect/2022-10-30.md", encoding="UTF-8") as parsed_result, \ + open("tests/files/scenarios/ok/expect/2022-10-30.md", encoding="UTF-8") as expected_result: + self.assertListEqual(expected_result.readlines(), parsed_result.readlines()) def tearDown(self) -> None: - folder = 'tests/files/output-results' + folder = 'tests/files/scenarios/ok/out' for filename in os.listdir(folder): file_path = os.path.join(folder, filename) try: diff --git a/tests/test_utils.py b/tests/test_utils.py index 4e1f1c1..c80a45d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,11 +1,13 @@ import logging +import datetime from unittest import TestCase -import tests.suppress as suppress from daylio_to_md import utils +import tests.suppress as suppress +from daylio_to_md.utils import guess_time_type, guess_date_type -class TestUtils(TestCase): +class TestSlugify(TestCase): @suppress.out def test_slugify(self): # no need to check if slug is a valid tag @@ -33,6 +35,8 @@ def test_slugify(self): # https://docs.python.org/3/library/unittest.html#unittest.TestCase.assertNoLogs self.assertListEqual(["WARNING:daylio_to_md.utils:Dummy warning"], logs.output) + +class TestExpandPath(TestCase): @suppress.out def test_expand_path(self): # noinspection SpellCheckingInspection @@ -40,11 +44,15 @@ def test_expand_path(self): # noinspection SpellCheckingInspection self.assertFalse(utils.expand_path('~/yes').startswith('~')) + +class TestStripping(TestCase): @suppress.out def test_strip_and_get_truthy(self): self.assertListEqual(["one", "two"], utils.strip_and_get_truthy("\"one||two|||||\"", "|")) self.assertListEqual([], utils.strip_and_get_truthy("\"\"", "|")) + +class TestSlicing(TestCase): @suppress.out def test_slice_quotes(self): self.assertEqual("test", utils.slice_quotes("\"test\"")) @@ -56,12 +64,12 @@ class TestIOContextManager(TestCase): @suppress.out def testJsonContextManager(self): expected_dict = {'rad': ['rad'], 'good': ['good'], 'neutral': ['okay'], 'bad': ['bad'], 'awful': ['awful']} - with utils.JsonLoader().load('tests/files/mood_JSONs/smallest_moodset_possible.json') as example_file: + with utils.JsonLoader().load('tests/files/moods/smallest.json') as example_file: self.assertDictEqual(expected_dict, example_file) @suppress.out def testCsvContextManager(self): - with utils.CsvLoader().load('tests/files/journal_CSVs/sheet-1-valid-data.csv') as example_file: + with utils.CsvLoader().load('tests/files/all-valid.csv') as example_file: expected_dict = { 'full_date': '2022-10-30', 'date': 'October 30', @@ -74,3 +82,109 @@ def testCsvContextManager(self): } # next() loads the first contentful line after csv column names self.assertDictEqual(expected_dict, next(example_file)) + + +class TestDateTimeGuessing(TestCase): + def test_12_hour_format(self): + self.assertEqual(guess_time_type("02:30 PM"), datetime.time(14, 30)) + self.assertEqual(guess_time_type("12:00 AM"), datetime.time(0, 0)) + self.assertEqual(guess_time_type("12:00 PM"), datetime.time(12, 0)) + + def test_24_hour_format(self): + self.assertEqual(guess_time_type("14:30"), datetime.time(14, 30)) + self.assertEqual(guess_time_type("00:00"), datetime.time(0, 0)) + self.assertEqual(guess_time_type("23:59"), datetime.time(23, 59)) + + def test_no_leading_zero(self): + self.assertEqual(guess_time_type("2:30 PM"), datetime.time(14, 30)) + self.assertEqual(guess_time_type("2:30"), datetime.time(2, 30)) + + def test_list_input(self): + self.assertEqual(guess_time_type([14, 30]), datetime.time(14, 30)) + self.assertEqual(guess_time_type([0, 0]), datetime.time(0, 0)) + self.assertEqual(guess_time_type([23, 59]), datetime.time(23, 59)) + + def test_time_object_input(self): + self.assertEqual(guess_time_type(datetime.time(14, 30)), datetime.time(14, 30)) + self.assertEqual(guess_time_type(datetime.time(0, 0)), datetime.time(0, 0)) + + def test_edge_cases(self): + self.assertEqual(guess_time_type("11:59 PM"), datetime.time(23, 59)) + self.assertEqual(guess_time_type("12:01 AM"), datetime.time(0, 1)) + + def test_invalid_inputs(self): + with self.assertRaises(utils.InvalidTimeError): + guess_time_type("25:00") + with self.assertRaises(utils.InvalidTimeError): + guess_time_type("14:60") + with self.assertRaises(utils.InvalidTimeError): + guess_time_type("2:30 ZM") + with self.assertRaises(utils.InvalidTimeError): + guess_time_type([14, 30, 0]) + with self.assertRaises(utils.InvalidTimeError): + guess_time_type([14]) + with self.assertRaises(utils.InvalidTimeError): + guess_time_type("not a time") + + def test_string_variations(self): + self.assertEqual(guess_time_type("2:30PM"), datetime.time(14, 30)) + self.assertEqual(guess_time_type("2:30 pm"), datetime.time(14, 30)) + self.assertEqual(guess_time_type("02:30pm"), datetime.time(14, 30)) + + def test_whitespace_handling(self): + self.assertEqual(guess_time_type(" 14:30 "), datetime.time(14, 30)) + self.assertEqual(guess_time_type("2:30 PM "), datetime.time(14, 30)) + + +class TestGuessDateType(TestCase): + def test_string_input(self): + self.assertEqual(guess_date_type("2023-05-15"), datetime.date(2023, 5, 15)) + self.assertEqual(guess_date_type("2000-01-01"), datetime.date(2000, 1, 1)) + self.assertEqual(guess_date_type("2099-12-31"), datetime.date(2099, 12, 31)) + self.assertEqual(guess_date_type("2023-5-15"), datetime.date(2023, 5, 15)) + self.assertEqual(guess_date_type(["2023", "05", "15"]), datetime.date(2023, 5, 15)) + + def test_list_input(self): + self.assertEqual(guess_date_type([2023, 5, 15]), datetime.date(2023, 5, 15)) + self.assertEqual(guess_date_type([2000, 1, 1]), datetime.date(2000, 1, 1)) + self.assertEqual(guess_date_type([2099, 12, 31]), datetime.date(2099, 12, 31)) + + def test_date_object_input(self): + test_date = datetime.date(2023, 5, 15) + self.assertEqual(guess_date_type(test_date), test_date) + + def test_edge_cases(self): + # Leap year + self.assertEqual(guess_date_type("2024-02-29"), datetime.date(2024, 2, 29)) + # Year boundaries + self.assertEqual(guess_date_type([1, 1, 1]), datetime.date(1, 1, 1)) + self.assertEqual(guess_date_type("9999-12-31"), datetime.date(9999, 12, 31)) + + def test_invalid_list_input(self): + with self.assertRaises(utils.InvalidDateError): + guess_date_type([2023, 5]) # Too few elements + with self.assertRaises(utils.InvalidDateError): + guess_date_type([2023, 5, 15, 12]) # Too many elements + with self.assertRaises(utils.InvalidDateError): + guess_date_type([2023, 13, 1]) # Invalid month + with self.assertRaises(utils.InvalidDateError): + guess_date_type([2023, 2, 30]) # Invalid day for February + + def test_list_with_mixed_type(self): + self.assertEqual(guess_date_type(["2023", 5, "15"]), datetime.date(2023, 5, 15)) + + def test_invalid_string_format(self): + with self.assertRaises(utils.InvalidDateError): + guess_date_type("2023/05/15") + with self.assertRaises(utils.InvalidDateError): + guess_date_type("15-05-2023") + + # noinspection PyTypeChecker + def test_invalid_types(self): + with self.assertRaises(utils.InvalidDateError): + guess_date_type(20230515) # Integer input + with self.assertRaises(utils.InvalidDateError): + guess_date_type({"year": 2023, "month": 5, "day": 15}) # Dictionary input + + def test_string_with_whitespace(self): + self.assertEqual(guess_date_type(" 2023-05-15 "), datetime.date(2023, 5, 15))