Skip to content

Commit

Permalink
Added CD to GitHub and PyPI, and added linting to CI (#2)
Browse files Browse the repository at this point in the history
* Added CD to GitHub and PyPI
* Add PyPy 3.9 as tested version
* Configure linting in CI and fix code to pass linting checks
  • Loading branch information
philippewarren authored Aug 31, 2022
1 parent 25f6166 commit c2de5c9
Show file tree
Hide file tree
Showing 17 changed files with 374 additions and 248 deletions.
1 change: 1 addition & 0 deletions .github/release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
changelog:
66 changes: 59 additions & 7 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,24 @@ on:
- '.github/workflows/**.yaml'
- 'setup.cfg'
- 'pyproject.toml'
pull_request:
paths:
- '**.py'
- '.github/workflows/**.yaml'
- 'setup.cfg'
- 'pyproject.toml'
workflow_dispatch:


jobs:
linting:
lint:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3

- name: Installing Python 3.10
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: "3.10"

Expand All @@ -27,21 +34,24 @@ jobs:
- name: Linting
run: |
python -m black --check calct tests
python -m isort --check-only calct tests
python -m mypy -p calct -p tests
python -m flake8 calct tests
python -m pylint calct tests
test:
needs: linting
needs: lint
strategy:
matrix:
os: [ubuntu-22.04, ubuntu-20.04, windows-latest]
python-version: ["3.9", "3.10"]
python-version: ["3.9", "3.10", "pypy3.9"]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3

- name: Installing Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}

Expand All @@ -58,3 +68,45 @@ jobs:
with:
name: coverage_${{ matrix.python-version }}_${{ matrix.os }}
path: ./coverage_html

release:
needs: test
runs-on: ubuntu-22.04
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch_depth: 0

- name: Installing Python 3.10
uses: actions/setup-python@v4
with:
python-version: "3.10"

- name: Installing tools and dependencies
run: |
python -m pip install --upgrade pip setuptools wheel twine build
- name: Build
run: |
python -m build
- name: Install wheel
run: |
python -m pip install $(find -wholename ./dist/*.whl)[test]
- name: Executing tests on build
run: |
python -m pytest -v --no-cov
- name: Releasing to GitHub
uses: softprops/action-gh-release@v1
with:
files: './dist/*'
fail_on_unmatched_files: true
generate_release_notes: true

- name: Deploying to PyPI
run: |
python -m twine upload ./dist/* -u '__token__' -p "${{ secrets.PYPI_TOKEN }}"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ __pycache__
htmlcov*
*.egg*
pip-wheel-metadata*
dist
calct/__version__.py
build
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
Easily do calculations on hours and minutes using the command line

## TODO
1. Add more tests
2. Custom parsing of durations without using `strptime` to support more formats
1. Improve README and documentation
2. Add more tests
3. Custom parsing of durations without using `strptime` to support more formats
- `25h` (bigger than 23h)
- `h61`, `61m` (bigger than 60m)
- `0.1h`(float format for hours)
5. Add CD and PyPI
22 changes: 18 additions & 4 deletions calct/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
__version__ = "0.1.0"
__year__ = "2022"
__author__ = "Philippe Warren"
__license__ = "GPLv3"
from calct.__version__ import __version__
from calct.duration import Duration
from calct.main import __author__, __license__, __year__, run_loop, run_once
from calct.parser import compute, evaluate_rpn, lex, parse

__all__ = [
"Duration",
"evaluate_rpn",
"lex",
"parse",
"compute",
"__version__",
"__year__",
"__author__",
"__license__",
"run_loop",
"run_once",
]
3 changes: 1 addition & 2 deletions calct/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,8 @@

from __future__ import annotations

from typing import Union

import string
from typing import Union

Number = Union[int, float]

Expand Down
76 changes: 38 additions & 38 deletions calct/duration.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,91 +17,102 @@
from __future__ import annotations

from datetime import timedelta
from time import strptime
from functools import total_ordering
from itertools import chain
from time import strptime

from calct.common import (
Number,
CANT_BE_CUSTOM_SEPARATOR,
DEFAULT_HOUR_SEPARATOR,
DEFAULT_MINUTE_SEPARATOR,
Number,
)


@total_ordering
class Duration:
"""Representation of a duration as hours and minutes."""

str_hour_sep: str = DEFAULT_HOUR_SEPARATOR[0]
str_minute_sep = DEFAULT_MINUTE_SEPARATOR[0]

@classmethod
def get_string_hour_minute_separator(cls) -> str:
"""Return the character used to separate hours and minutes."""
return cls.str_hour_sep

@classmethod
def set_string_hour_minute_separator(cls, sep: str) -> None:
"""Set the character used to separate hours and minutes."""
if not isinstance(sep, str) or not len(sep) == 1: # type:ignore
raise TypeError("Separator needs to be a one-character string")
if set(sep) & set(CANT_BE_CUSTOM_SEPARATOR + DEFAULT_MINUTE_SEPARATOR) != set():
raise ValueError(
f"Separator can't contain a character from `{''.join(set(CANT_BE_CUSTOM_SEPARATOR + DEFAULT_MINUTE_SEPARATOR))}` or it would break the parser"
"Separator can't contain a character from "
f"`{''.join(set(CANT_BE_CUSTOM_SEPARATOR + DEFAULT_MINUTE_SEPARATOR))}`"
"or it would break the parser"
)
cls.str_hour_sep = sep

@classmethod
def del_string_hour_minute_separator(cls) -> None:
"""Restore the default separator for hours and minutes."""
cls.str_hour_sep = DEFAULT_HOUR_SEPARATOR[0]

def __init__(self, hours: Number = 0, minutes: Number = 0) -> None:
self.minutes = hours * 60 + minutes
def __init__(self, hours: Number = 0, minutes: int = 0) -> None:
self.minutes: int = int(hours * 60) + minutes

@property
def hours(self) -> Number:
def hours(self) -> int:
"""The `hours` part of the duration, truncated"""
return divmod(self.minutes, 60)[0]

@hours.setter
def hours(self, new_hours: Number) -> None:
self.minutes = new_hours * 60
self.minutes = int(new_hours * 60)

@staticmethod
def from_timedelta(td: timedelta) -> Duration:
return Duration(minutes=td.total_seconds() / 60)
def from_timedelta(time_delta: timedelta) -> Duration:
"""Create a Duration from a timedelta."""
return Duration(minutes=int(time_delta.total_seconds() / 60))

@classmethod
def get_hour_seps(cls) -> set[str]:
"""Return the set of characters used to separate hours and minutes."""
return set(DEFAULT_HOUR_SEPARATOR) | {cls.str_hour_sep}

@classmethod
def get_minute_seps(cls) -> set[str]:
"""Return the set of characters used to indicate minutes."""
return set(DEFAULT_MINUTE_SEPARATOR) | {cls.str_minute_sep}

@classmethod
def get_hour_and_minute_seps(cls) -> set[str]:
"""Return the set of characters used to separate hours and minutes, or indicate minutes."""
return cls.get_hour_seps() | cls.get_minute_seps()

@classmethod
def get_matchers(cls) -> set[str]:
matchers_hours = chain.from_iterable(
(f"%H{sep}%M", f"%H{sep}", f"{sep}%M") for sep in cls.get_hour_seps()
)
matchers_minutes = chain.from_iterable(
(f"%M{sep}",) for sep in cls.get_minute_seps()
)
"""Return the set of strings matchers that can be used to parse a duration."""
matchers_hours = chain.from_iterable((f"%H{sep}%M", f"%H{sep}", f"{sep}%M") for sep in cls.get_hour_seps())
matchers_minutes = chain.from_iterable((f"%M{sep}",) for sep in cls.get_minute_seps())

return set(matchers_hours) | set(matchers_minutes)

@classmethod
def parse(cls, time_str: str) -> Duration:
"""Create a Duration from a string."""
for matcher in cls.get_matchers():
try:
t = strptime(time_str, matcher)
return Duration(hours=t.tm_hour, minutes=t.tm_min)
time = strptime(time_str, matcher)
return Duration(hours=time.tm_hour, minutes=time.tm_min)
except ValueError:
pass
raise ValueError(f"Invalid time: {time_str}")

@property
def hours_minutes(self) -> tuple[float, float]:
def hours_minutes(self) -> tuple[int, int]:
"""The `hours` and `minutes` parts of the duration."""
return divmod(self.minutes, 60)

def __str__(self) -> str:
Expand All @@ -114,49 +125,38 @@ def __repr__(self) -> str:

def __eq__(self, other: object) -> bool:
if not isinstance(other, Duration): # type: ignore
raise TypeError(
f"unsupported operand type(s) for ==: '{type(self)}' and '{type(other)}'"
)
raise TypeError(f"unsupported operand type(s) for ==: '{type(self)}' and '{type(other)}'")
return self.minutes == other.minutes

def __lt__(self, other: Duration) -> bool:
if not isinstance(other, Duration): # type: ignore
raise TypeError(
f"unsupported operand type(s) for <: '{type(self)}' and '{type(other)}'"
)
raise TypeError(f"unsupported operand type(s) for <: '{type(self)}' and '{type(other)}'")
return self.minutes < other.minutes

def __add__(self, other: Duration) -> Duration:
if not isinstance(other, Duration): # type: ignore
raise TypeError(
f"unsupported operand type(s) for +: '{type(self)}' and '{type(other)}'"
)
raise TypeError(f"unsupported operand type(s) for +: '{type(self)}' and '{type(other)}'")
return Duration(minutes=self.minutes + other.minutes)

def __sub__(self, other: Duration) -> Duration:
if not isinstance(other, Duration): # type: ignore
raise TypeError(
f"unsupported operand type(s) for -: '{type(self)}' and '{type(other)}'"
)
raise TypeError(f"unsupported operand type(s) for -: '{type(self)}' and '{type(other)}'")
return Duration(minutes=self.minutes - other.minutes)

def __mul__(self, other: Number) -> Duration:
if not isinstance(other, (int, float)): # type: ignore
raise TypeError(
f"unsupported operand type(s) for *: '{type(self)}' and '{type(other)}'"
)
return Duration(minutes=self.minutes * other)
raise TypeError(f"unsupported operand type(s) for *: '{type(self)}' and '{type(other)}'")
return Duration(minutes=int(self.minutes * other))

def __rmul__(self, other: Number) -> Duration:
return self.__mul__(other)

def __truediv__(self, other: Number) -> Duration:
if not isinstance(other, (int, float)): # type: ignore
raise TypeError(
f"unsupported operand type(s) for /: '{type(self)}' and '{type(other)}'"
)
return Duration(minutes=self.minutes / other)
raise TypeError(f"unsupported operand type(s) for /: '{type(self)}' and '{type(other)}'")
return Duration(minutes=int(self.minutes / other))

@property
def as_timedelta(self) -> timedelta:
"""Return the duration as a timedelta."""
return timedelta(minutes=self.minutes)
Loading

0 comments on commit c2de5c9

Please sign in to comment.