Skip to content

Commit

Permalink
Lint fixes, add basic typing, allow typing to fail without failing build
Browse files Browse the repository at this point in the history
  • Loading branch information
n8henrie committed Sep 11, 2022
1 parent 2513e15 commit d199bab
Show file tree
Hide file tree
Showing 12 changed files with 115 additions and 110 deletions.
12 changes: 12 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,15 @@ dev = [
[tool.setuptools.dynamic]
version = {attr = "icw.__version__"}
readme = {file = ["README.md", "CHANGELOG.md"]}

[tool.mypy]
check_untyped_defs = true
disallow_untyped_calls = true
disallow_untyped_defs = true
follow_imports = "silent"
ignore_missing_imports = true
python_version = "3.10"
show_column_numbers = true
warn_incomplete_stub = false
warn_redundant_casts = true
warn_unused_ignores = true
2 changes: 1 addition & 1 deletion src/icw/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Top level of icw.py"""
"""icw :: Flask-based Webapp to convert a csv file into an icalendar file."""

from flask import Flask
from flask_bootstrap import Bootstrap
Expand Down
6 changes: 3 additions & 3 deletions src/icw/config.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""config.py
Contains the default config class for icw.
"""
"""Config classes for icw."""


class DefaultConfig(object):
"""Contains the default config class for icw."""

DEBUG = False
ALLOWED_EXTENSIONS = set(["csv"])
SECRET_KEY = "SUPER_SECRET_KEY"
Expand Down
111 changes: 60 additions & 51 deletions src/icw/converter.py
Original file line number Diff line number Diff line change
@@ -1,65 +1,70 @@
"""converter.py
Does the meat of the file conversion for icw.
"""
"""Converts csv files to ics files."""

import codecs
import csv
import typing as t
import uuid
from collections import Counter
from datetime import datetime, timedelta

import chardet
from icalendar import Calendar, Event, LocalTimezone
from werkzeug.datastructures import FileStorage

from icw import app

app.logger.debug("Starting converter in debug mode.")


class BaseICWError(Exception):
def __str__(self):
"""Base class for icw errors."""

def __str__(self) -> str:
"""Pretty error string."""
return f"{self.__class__.__name__}: {self.args[0]}"


class HeadersError(Exception):
...
"""Error with headers."""


class DatetimeFormatError(BaseICWError):
...
"""Error in input datetime format."""


class ContentError(BaseICWError):
...

"""Error in the body of the input data."""

def unicode_csv_reader(upfile, **kwargs):
"""Python's csv module doesn't like unicode. This is a workaround."""

def unicode_csv_reader(
upfile: FileStorage, **kwargs: t.Any
) -> t.Iterable[list[str]]:
"""Workaround to decode data prior to passing to CSV module."""
updata = upfile.read()

# strip out BOM if present
if updata.startswith(codecs.BOM_UTF8):
updata = updata[len(codecs.BOM_UTF8) :]
idx = len(codecs.BOM_UTF8)
updata = updata[idx:]

# splitlines lets us respect universal newlines
def line_decoder(updata):
def line_decoder(updata: bytes) -> t.Iterable[str]:
for line in updata.splitlines():
try:
line = line.decode()
line_str = line.decode()
except UnicodeDecodeError:
encoding = chardet.detect(updata).get("encoding")
encoding = chardet.detect(updata)["encoding"]
app.logger.warning(
"Had UnicodeDecodeError, now trying with {encoding}"
f"Had UnicodeDecodeError, now trying with {encoding}"
)
# Retry the line, uncaught exception if still not right
line = line.decode(encoding)
yield line
line_str = line.decode(encoding)
yield line_str

yield from csv.reader(line_decoder(updata), **kwargs)


def check_headers(headers):
def check_headers(headers: list[str]) -> list[str]:
"""Ensure sure that all headers are exactly correct.
This ensures the headers will be recognized as the necessary keys.
Expand All @@ -79,8 +84,8 @@ def check_headers(headers):

if not sorted(headers) == sorted(valid_keys):
app.logger.info(
"Problem in the check_headers function. Headers: "
"{}".format(", ".join(headers))
"Problem in the check_headers function. "
f"Headers: {', '.join(headers)}"
)
errmsg = "Something isn't right with the headers."
try:
Expand All @@ -89,45 +94,47 @@ def check_headers(headers):
missing = set(valid_keys) - set(headers)
if extras:
extras_str = ", ".join(extras.elements())
errmsg += " Extra or misspelled keys: {}.".format(extras_str)
errmsg += f" Extra or misspelled keys: {extras_str}."
if missing:
missing_str = ", ".join(missing)
errmsg += " Missing keys: {}.".format(missing_str)
errmsg += f" Missing keys: {missing_str}."
except Exception as e:
app.logger.exception(e)

raise HeadersError(errmsg)
else:
return headers
return headers


def clean_spaces(csv_dict):
"""Cleans trailing spaces from the dictionary
values, which can break my datetime patterns."""
clean_row = {}
for row in csv_dict:
for k, v in row.items():
if v:
clean_row.update({k: v.strip()})
else:
clean_row.update({k: None})
def clean_spaces(
csv_dict: t.Iterable[dict[str, str]]
) -> t.Iterable[dict[str, str | None]]:
"""Clean trailing spaces from the dictionary values.
yield clean_row
Trailing spaces can break my datetime patterns.
"""
yield from (
{k: (v.strip() if v else None) for k, v in row.items()}
for row in csv_dict
)


def check_dates_and_times(
start_date, start_time, end_date, end_time, all_day, rownum
):
"""Checks the dates and times to make sure everything is kosher."""

start_date: str | None,
start_time: str | None,
end_date: str | None,
end_time: str | None,
all_day: bool | None,
rownum: int | None,
) -> None:
"""Check the dates and times to make sure everything is kosher."""
app.logger.debug("Date checker started.")

# Gots to have a start date, no matter what.
if start_date in ["", None]:
app.logger.error("Missing a start date at row {}".format(rownum))
app.logger.error(f"Missing a start date at row {rownum}")
errmsg = "Missing a start date"
try:
errmsg += " around row number {}.".format(rownum)
errmsg += f" around row number {rownum}."
except Exception:
pass
raise DatetimeFormatError(errmsg)
Expand All @@ -138,16 +145,16 @@ def check_dates_and_times(
datetime.strptime(date, "%m/%d/%Y")
except ValueError as e:
errmsg = (
"Something isn't right with a date in row {}. Make "
"sure you're using MM/DD/YYYY format.".format(rownum)
f"Something isn't right with a date in row {rownum}. "
"Make sure you're using MM/DD/YYYY format."
)
try:
bad_date = e.args[0].split("'")[1]
errmsg += " Problematic date: " + bad_date
except Exception:
pass

raise DatetimeFormatError(errmsg)
raise DatetimeFormatError(errmsg) from e

for time in [start_time, end_time]:
if time not in ["", None]:
Expand All @@ -159,17 +166,17 @@ def check_dates_and_times(
datetime.strptime(time, "%H:%M")
except ValueError as e:
errmsg = (
"Something isn't right with a time in row {}. Make "
"sure you're using either '1:00 PM' or '13:00' "
"format.".format(rownum)
"Something isn't right with a time in row {rownum}. "
"Make sure you're using either '1:00 PM' or '13:00' "
"format."
)
try:
bad_time = e.args[0].split("'")[1]
errmsg += " Problematic time: " + bad_time
except Exception:
pass

raise DatetimeFormatError(errmsg)
raise DatetimeFormatError(errmsg) from e

if all_day is None or all_day.lower() != "true":
if not (start_time and end_time):
Expand All @@ -179,14 +186,16 @@ def check_dates_and_times(
)
errmsg = (
'Unless an event is "all day," it needs both a start '
"and end time. Double check row {}.".format(rownum)
"and end time. Double check row {rownum}."
)
raise DatetimeFormatError(errmsg)

app.logger.debug("Date checker ended.")


def convert(upfile):
def convert(upfile: t.IO) -> bytes:
"""Convert the file."""
breakpoint()
reader_builder = unicode_csv_reader(upfile, skipinitialspace=True)

reader_list = list(reader_builder)
Expand All @@ -211,7 +220,7 @@ def convert(upfile):
app.logger.debug("Event {} started, contents:\n{}".format(rownum, row))

# No blank subjects, skip row if subject is None or ''
if row.get("Subject") in ["", None]:
if row.get("Subject", "") in ["", None]:
continue

event = Event()
Expand Down
3 changes: 3 additions & 0 deletions src/icw/forms.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""Forms for icw."""

from flask_wtf import FlaskForm
from flask_wtf.file import FileAllowed, FileField, FileRequired
from wtforms import SubmitField


class UploadForm(FlaskForm):
"""Form that accepts the CSV input file."""

validators = [
FileRequired(message="There was no file!"),
Expand Down
40 changes: 22 additions & 18 deletions src/icw/views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Flask views for icw."""
import typing as t
import uuid
from pathlib import Path

Expand All @@ -10,10 +12,10 @@
session,
url_for,
)
from flask.wrappers import Response

import icw
from icw import app

from . import __version__ as icw_version
from . import app
from .converter import ContentError, convert, DatetimeFormatError, HeadersError
from .forms import UploadForm

Expand All @@ -31,8 +33,8 @@


@app.route("/", methods=["GET", "POST"])
def index():

def index() -> Response:
"""Provide default route."""
form = UploadForm()
if request.method == "POST" and form.validate_on_submit():
key = str(uuid.uuid4())
Expand All @@ -52,7 +54,7 @@ def index():
form=form,
links=base_links,
links_title=LINKS_TITLE,
version=icw.__version__,
version=icw_version,
)

else:
Expand All @@ -63,20 +65,21 @@ def index():
return redirect(url_for("success"))

for _, errors in form.errors.items():
for error in errors:
msg = "Whups! {}".format(error)
for err in errors:
msg = f"Whups! {err}"
flash(msg)
return render_template(
"index.html",
form=form,
links=base_links,
links_title=LINKS_TITLE,
version=icw.__version__,
version=icw_version,
)


@app.route("/success")
def success():
def success() -> Response:
"""Provide route for successful conversion."""
links = [
{"url": "/", "description": "Convert another file"},
{
Expand All @@ -91,12 +94,13 @@ def success():
"success.html",
links=links,
links_title="Where to next?",
version=icw.__version__,
version=icw_version,
)


@app.route("/download")
def download():
def download() -> Response:
"""Provide route for downloading converted file."""
key = session["key"]
fullpath = "/tmp/" + key + ".ics"
mtype = "text/calendar"
Expand All @@ -112,13 +116,13 @@ def download():


@app.errorhandler(404)
def error_404(_):
def error_404(_: t.Any) -> Response:
"""Return a custom 404 error."""
return "Sorry, Nothing at this URL.", 404
return make_response("Sorry, Nothing at this URL.", 404)


@app.errorhandler(500)
def error_500(e):
def error_500(e) -> Response:
"""Return a custom 500 error."""
app.logger.exception(e)
app.logger.warning(repr(e))
Expand All @@ -127,7 +131,7 @@ def error_500(e):
f"Sorry, unexpected error: {e}<br/><br/>"
"This shouldn't have happened. Would you mind "
'<a href="https://n8henrie.com/contact">sending me</a> '
"a message regarding what caused this (and the file if "
"possible)? Thanks -Nate"
"a message regarding what caused this (and the file if possible)? "
"Thanks -Nate"
)
return msg, 500
return make_response(msg, 500)
Loading

0 comments on commit d199bab

Please sign in to comment.