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