From eefccd9831f1e2ee363d83833abac88b873e966b Mon Sep 17 00:00:00 2001 From: Richard Darst Date: Wed, 26 Jan 2022 20:58:39 +0200 Subject: [PATCH 1/3] Add Github Actions to run tests - Very minimal, using the suggested action template for a Python action - Must set PYTHONPATH when running pytest since yaml2ics is in current directory. - Review: automerge if tests pass :) --- .github/workflows/python-app.yml | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/python-app.yml diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..1b1fd9f --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,36 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python application + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.10 + uses: actions/setup-python@v2 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + PYTHONPATH=. pytest From 3fa966d91a8b8bcc207a1fffe2ddc1a0e2151575 Mon Sep 17 00:00:00 2001 From: Stefan van der Walt Date: Wed, 26 Jan 2022 11:51:14 -0800 Subject: [PATCH 2/3] Use pre-commit for checks, and add isort and black --- .github/workflows/python-app.yml | 36 -------------------------------- .github/workflows/test.yaml | 31 +++++++++++++++++++++++++++ .pre-commit-config.yaml | 17 +++++++++++++++ requirements.dev.txt | 2 ++ 4 files changed, 50 insertions(+), 36 deletions(-) delete mode 100644 .github/workflows/python-app.yml create mode 100644 .github/workflows/test.yaml create mode 100644 .pre-commit-config.yaml create mode 100644 requirements.dev.txt diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml deleted file mode 100644 index 1b1fd9f..0000000 --- a/.github/workflows/python-app.yml +++ /dev/null @@ -1,36 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - -name: Python application - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.10 - uses: actions/setup-python@v2 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - PYTHONPATH=. pytest diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..7b60b4d --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,31 @@ +name: test + +on: [push, pull_request] + +jobs: + default: + runs-on: ${{ matrix.os }}-latest + strategy: + matrix: + os: [ubuntu] + python-version: [3.10] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + pip install -r requirements.txt -r requirements.dev.txt + + - name: Lint + run: pre-commit run --all-files --show-diff-on-failure --color always + + - name: Test + run: | + PYTHONPATH=. pytest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4dc09c1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +# Install pre-commit hooks via +# pre-commit install + +repos: + - repo: https://github.com/psf/black + rev: 21.5b1 + hooks: + - id: black + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.4 + hooks: + - id: flake8 + pass_filenames: true + - repo: https://github.com/pycqa/isort + rev: 5.10.1 + hooks: + - id: isort diff --git a/requirements.dev.txt b/requirements.dev.txt new file mode 100644 index 0000000..efc4154 --- /dev/null +++ b/requirements.dev.txt @@ -0,0 +1,2 @@ +black==21.5b1 +pre-commit>=2.12 From cf800dab674952350b0998e0fde16720d6e3b191 Mon Sep 17 00:00:00 2001 From: Stefan van der Walt Date: Wed, 26 Jan 2022 11:53:21 -0800 Subject: [PATCH 3/3] Black and flake8 fixes --- tests/test_calendar.py | 52 +++++++++++++----------- tests/test_events.py | 53 ++++++++++++------------- tests/util.py | 8 ++-- yaml2ics.py | 89 +++++++++++++++++++++++------------------- 4 files changed, 107 insertions(+), 95 deletions(-) diff --git a/tests/test_calendar.py b/tests/test_calendar.py index d720a91..1fe9812 100644 --- a/tests/test_calendar.py +++ b/tests/test_calendar.py @@ -2,38 +2,44 @@ import io import textwrap -from util import parse_yaml - from yaml2ics import events_to_calendar, files_to_calendar def test_calendar_structure(): cal = events_to_calendar([]) cal_str = cal.serialize() - assert cal_str.startswith('BEGIN:VCALENDAR') - assert cal_str.endswith('END:VCALENDAR') + assert cal_str.startswith("BEGIN:VCALENDAR") + assert cal_str.endswith("END:VCALENDAR") + def test_calendar_event(): cal = files_to_calendar( - [io.StringIO(textwrap.dedent( - ''' + [ + io.StringIO( + textwrap.dedent( + """ events: - summary: Earth Day begin: 2021-04-22 url: https://earthday.org location: Earth - ''' - ))] + """ + ) + ) + ] ) cal_str = cal.serialize() - assert cal_str.startswith('BEGIN:VCALENDAR') - assert 'SUMMARY:Earth Day' in cal_str - assert cal_str.endswith('END:VCALENDAR') + assert cal_str.startswith("BEGIN:VCALENDAR") + assert "SUMMARY:Earth Day" in cal_str + assert cal_str.endswith("END:VCALENDAR") + def test_calendar_default_timezone(): cal = files_to_calendar( - [io.StringIO(textwrap.dedent( - ''' + [ + io.StringIO( + textwrap.dedent( + """ meta: tz: Europe/Helsinki @@ -48,23 +54,25 @@ def test_calendar_default_timezone(): - summary: Earth day (all day) begin: 2022-04-22 - ''' - ))] + """ + ) + ) + ] ) cal_str = cal.serialize() - assert cal_str.startswith('BEGIN:VCALENDAR') - assert 'SUMMARY:New year' in cal_str + assert cal_str.startswith("BEGIN:VCALENDAR") + assert "SUMMARY:New year" in cal_str # It is possible that the ics-py TZID string changes, but hopefully this # substring is fairly safe to test against. - assert 'Europe/Helsinki:20220101T000000' in cal_str + assert "Europe/Helsinki:20220101T000000" in cal_str # Second event: explicit UTC offset specified. assert '"UTC+02:00":20220201T000000' in cal_str - assert cal_str.endswith('END:VCALENDAR') + assert cal_str.endswith("END:VCALENDAR") # Test again by normalizing to UTC. Helsinki is two hours ahead, so the # times should be 22:00:00. cal.normalize(datetime.timezone.utc) - cal_norm_str = cal.serialize() + cal_norm_str = cal.serialize() # noqa: F841 # 1 Feb midnight - assert 'DTSTART:20211231T220000Z' # 1 jan - assert 'DTSTART:20220131T220000Z' # 1 feb + assert "DTSTART:20211231T220000Z" # 1 jan + assert "DTSTART:20220131T220000Z" # 1 feb diff --git a/tests/test_events.py b/tests/test_events.py index 7dfbc77..26ac026 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -6,49 +6,49 @@ def test_basic_structure(): event = event_from_yaml( parse_yaml( - ''' + """ summary: Earth Day begin: 2021-04-22 url: https://earthday.org location: Earth - ''' + """ ) ) # All lines must be separated by CRLF event_str = event.serialize() - lines = event_str.split('\n') + lines = event_str.split("\n") for line in lines[:-1]: - assert line.endswith('\r') - assert 'SUMMARY:Earth Day' in event_str - assert 'URL:https://earthday.org' in event_str - assert 'LOCATION:Earth' in event_str + assert line.endswith("\r") + assert "SUMMARY:Earth Day" in event_str + assert "URL:https://earthday.org" in event_str + assert "LOCATION:Earth" in event_str # All events must have a DTSTAMP - assert 'DTSTAMP' in event_str + assert "DTSTAMP" in event_str def test_all_day_event(): event = event_from_yaml( parse_yaml( - ''' + """ summary: Earth Day begin: 2021-04-22 url: https://earthday.org - ''' + """ ) ) event_str = event.serialize() - assert event_str.startswith('BEGIN:VEVENT') - assert event_str.endswith('END:VEVENT') - assert 'DTSTART;VALUE=DATE:20210422' in event_str + assert event_str.startswith("BEGIN:VEVENT") + assert event_str.endswith("END:VEVENT") + assert "DTSTART;VALUE=DATE:20210422" in event_str # ics 0.8.0 does have DTEND that is the next day. - #assert 'DTEND' not in event_str + # assert 'DTEND' not in event_str def test_rrule(): event = event_from_yaml( parse_yaml( - ''' + """ summary: Earth Day begin: 2021-04-22 url: https://earthday.org @@ -56,47 +56,46 @@ def test_rrule(): interval: years: 1 until: 2030-04-22 - ''' + """ ) ) event_str = event.serialize() # DTEND exists and is the next day, calendar tools import this # correctly as being a one-day event - assert 'RRULE:FREQ=YEARLY;UNTIL=20300422T000000' in event_str + assert "RRULE:FREQ=YEARLY;UNTIL=20300422T000000" in event_str def test_event_with_time_range(): event = event_from_yaml( parse_yaml( - ''' + """ summary: Event of the Century begin: 2021-09-21 15:00:00 -07:00 end: 2021-09-21 15:30:00 -07:00 description: | Meet the team on the northern side of the field. - ''' + """ ) ) event_str = event.serialize() - assert 'DTSTART' in event_str - assert 'DTEND' in event_str + assert "DTSTART" in event_str + assert "DTEND" in event_str def test_event_with_duration(): event = event_from_yaml( parse_yaml( - ''' + """ summary: Event of the Century begin: 2021-09-21 15:00:00 -07:00 duration: minutes: 30 description: | Meet the team on the northern side of the field. - ''' + """ ) ) event_str = event.serialize() - assert 'DURATION:PT30M' in event_str - assert 'DTEND' not in event_str - assert 'DTSTART' in event_str - + assert "DURATION:PT30M" in event_str + assert "DTEND" not in event_str + assert "DTSTART" in event_str diff --git a/tests/util.py b/tests/util.py index 2c2253c..958f26f 100644 --- a/tests/util.py +++ b/tests/util.py @@ -1,9 +1,7 @@ -import yaml import textwrap +import yaml + def parse_yaml(yaml_str): - return yaml.load( - textwrap.dedent(yaml_str).strip(), - Loader=yaml.FullLoader - ) + return yaml.load(textwrap.dedent(yaml_str).strip(), Loader=yaml.FullLoader) diff --git a/yaml2ics.py b/yaml2ics.py index 3481594..5e511b6 100644 --- a/yaml2ics.py +++ b/yaml2ics.py @@ -1,28 +1,27 @@ -import yaml -import sys import os +import sys from datetime import datetime, tzinfo -import ics import dateutil import dateutil.rrule +import ics +import yaml from dateutil.tz import gettz - interval_type = { - 'seconds': dateutil.rrule.SECONDLY, - 'minutes': dateutil.rrule.MINUTELY, - 'hours': dateutil.rrule.HOURLY, - 'days': dateutil.rrule.DAILY, - 'weeks': dateutil.rrule.WEEKLY, - 'months': dateutil.rrule.MONTHLY, - 'years': dateutil.rrule.YEARLY + "seconds": dateutil.rrule.SECONDLY, + "minutes": dateutil.rrule.MINUTELY, + "hours": dateutil.rrule.HOURLY, + "days": dateutil.rrule.DAILY, + "weeks": dateutil.rrule.WEEKLY, + "months": dateutil.rrule.MONTHLY, + "years": dateutil.rrule.YEARLY, } -def event_from_yaml(event_yaml: dict, tz: tzinfo=None) -> ics.Event: +def event_from_yaml(event_yaml: dict, tz: tzinfo = None) -> ics.Event: d = event_yaml - repeat = d.pop('repeat', None) + repeat = d.pop("repeat", None) # Strip all string values, since they often end on `\n` for key in d: @@ -37,42 +36,50 @@ def event_from_yaml(event_yaml: dict, tz: tzinfo=None) -> ics.Event: event = ics.Event(**d) # Handle all-day events - if not ('duration' in d or 'end' in d): + if not ("duration" in d or "end" in d): event.make_all_day() if repeat: - interval = repeat['interval'] + interval = repeat["interval"] if not len(interval) == 1: - print('Error: interval must specify seconds, minutes, hours, days, weeks, months, or years only', file=sys.stderr) + print( + "Error: interval must specify seconds, minutes, hours, days, weeks, months, or years only", + file=sys.stderr, + ) sys.exit(-1) interval_measure = list(interval.keys())[0] if interval_measure not in interval_type: - print('Error: expected interval to be specified in seconds, minutes, hours, days, weeks, months, or years only', file=sys.stderr) + print( + "Error: expected interval to be specified in seconds, minutes, hours, days, weeks, months, or years only", + file=sys.stderr, + ) sys.exit(-1) - if 'until' not in repeat: - print('Error: must specify end date for repeating events', file=sys.stderr) + if "until" not in repeat: + print("Error: must specify end date for repeating events", file=sys.stderr) sys.exit(-1) # This causes zero-length events, I guess overriding whatever duration might have been specified. - #event.end = d.get('end', None) + # event.end = d.get('end', None) rrule = dateutil.rrule.rrule( freq=interval_type[interval_measure], - until=repeat.get('until'), - dtstart=d['begin'] + until=repeat.get("until"), + dtstart=d["begin"], ) - rrule_lines = str(rrule).split('\n') - rrule_dtstart = [line for line in rrule_lines if line.startswith('DTSTART')][0] - rrule_dtstart = rrule_dtstart + 'Z' - rrule_rrule = [line for line in rrule_lines if line.startswith('RRULE')][0] - event.extra.append(ics.ContentLine( - name=rrule_rrule.split(':', 1)[0], - value=rrule_rrule.split(':', 1)[1], - )) + rrule_lines = str(rrule).split("\n") + rrule_dtstart = [line for line in rrule_lines if line.startswith("DTSTART")][0] + rrule_dtstart = rrule_dtstart + "Z" + rrule_rrule = [line for line in rrule_lines if line.startswith("RRULE")][0] + event.extra.append( + ics.ContentLine( + name=rrule_rrule.split(":", 1)[0], + value=rrule_rrule.split(":", 1)[1], + ) + ) event.dtstamp = datetime.utcnow().replace(tzinfo=dateutil.tz.UTC) if tz and event.floating and not event.all_day: @@ -86,34 +93,34 @@ def events_to_calendar(events: list) -> str: cal.events.append(event) return cal + def files_to_calendar(files: list) -> ics.Calendar: """'main' function: list of files to our result""" - all_events = [ ] - name = None + all_events = [] for f in files: - if hasattr(f, 'read'): + if hasattr(f, "read"): calendar_yaml = yaml.load(f.read(), Loader=yaml.FullLoader) else: - calendar_yaml = yaml.load(open(f, 'r'), Loader=yaml.FullLoader) + calendar_yaml = yaml.load(open(f, "r"), Loader=yaml.FullLoader) tz = None - if 'meta' in calendar_yaml: - if 'tz' in calendar_yaml['meta']: - tz = gettz(calendar_yaml['meta']['tz']) - for event in calendar_yaml['events']: + if "meta" in calendar_yaml: + if "tz" in calendar_yaml["meta"]: + tz = gettz(calendar_yaml["meta"]["tz"]) + for event in calendar_yaml["events"]: all_events.append(event_from_yaml(event, tz=tz)) calendar = events_to_calendar(all_events) return calendar -if __name__ == '__main__': +if __name__ == "__main__": if len(sys.argv) < 2: - print('Usage: yaml2ics.py FILE1.yaml FILE2.yaml ...') + print("Usage: yaml2ics.py FILE1.yaml FILE2.yaml ...") sys.exit(-1) files = sys.argv[1:] for f in files: if not os.path.isfile(f): - print(f'Error: {f} is not a file', file=sys.stderr) + print(f"Error: {f} is not a file", file=sys.stderr) sys.exit(-1) calendar = files_to_calendar(files)