From 2bbb594d62864883ead91d4710e09fd65e5e2855 Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Wed, 15 Dec 2021 23:09:37 +0100 Subject: [PATCH 01/12] Add informal Parser interface Added first informal parser interface, only requiring a method to parse events and one to parse trackers. Theoretically we only *require* a method to parse events since, through their contained activities, they would also come with trackers. But this would 1) be much more opaque and a lot of work to then extract the trackers again and 2) leave out trackers which do not yet have any activities associated with them (i.e. trackers never once accomplished). We can still turn the informal parser into a formal interface if need arises: https://realpython.com/python-interface/ --- src/habitmove/nomiedata.py | 2 +- src/habitmove/parser.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 src/habitmove/parser.py diff --git a/src/habitmove/nomiedata.py b/src/habitmove/nomiedata.py index 75a4189..60d2670 100644 --- a/src/habitmove/nomiedata.py +++ b/src/habitmove/nomiedata.py @@ -47,7 +47,7 @@ class Event: id: str start: int end: int - text: str + text: str = "" activities: list[Activity] = field(default_factory=lambda: []) score: int = 0 lat: float = 0.0 diff --git a/src/habitmove/parser.py b/src/habitmove/parser.py new file mode 100644 index 0000000..f33499e --- /dev/null +++ b/src/habitmove/parser.py @@ -0,0 +1,16 @@ +from habitmove.nomiedata import Event, Tracker + + +class Parser: + def __init__(self, path: str, filename: str) -> None: + """Load in a data set""" + self.path = path + self.filename = filename + + def extract_trackers(self) -> list[Tracker]: + """Extract trackers from the data set""" + raise NotImplementedError + + def extract_events(self) -> list[Event]: + """Extract events from the data set""" + raise NotImplementedError From 539a983505b456a757fa2b91696fb0b3e2b33a03 Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Thu, 16 Dec 2021 13:07:09 +0100 Subject: [PATCH 02/12] Prepare pytest for end-to-end testing --- .gitignore | 4 ++-- noxfile.py | 2 +- tests/conftest.py | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 tests/conftest.py diff --git a/.gitignore b/.gitignore index 7397c55..391abf2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -data/ -output.db +/data/ +/output.db # Created by https://www.toptal.com/developers/gitignore/api/vim,linux,python,pandas # Edit at https://www.toptal.com/developers/gitignore?templates=vim,linux,python,pandas diff --git a/noxfile.py b/noxfile.py index f056b7a..b5d7a12 100644 --- a/noxfile.py +++ b/noxfile.py @@ -3,7 +3,7 @@ import nox @nox.session(python=["3.7", "3.8", "3.9"]) def tests(session): - args = session.posargs or ["--cov"] + args = session.posargs or ["--cov", "-m", "not e2e"] session.run("poetry", "install", external=True) session.run("pytest", *args) pass diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0f09c94 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,2 @@ +def pytest_configure(config): + config.addinivalue_line("markers", "e2e: mark as end to end test.") From 70c626b74834fdca94453e1caad9be19563fb7ec Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Thu, 16 Dec 2021 13:09:52 +0100 Subject: [PATCH 03/12] Rename NomieImport to ImportData --- src/habitmove/cli.py | 6 +++--- src/habitmove/nomie.py | 8 ++++---- src/habitmove/nomiedata.py | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/habitmove/cli.py b/src/habitmove/cli.py index 489cb8a..6e4141d 100755 --- a/src/habitmove/cli.py +++ b/src/habitmove/cli.py @@ -3,13 +3,13 @@ import habitmove.schema as schema import habitmove.habits as habits import habitmove.repetitions as rep import habitmove.nomie as nomie -from habitmove.nomiedata import NomieImport +from habitmove.nomiedata import ImportData import click from . import __version__ -def migrate(data: NomieImport): +def migrate(data: ImportData): db = schema.migrate("output.db") if not db: raise ConnectionError @@ -29,7 +29,7 @@ def migrate(data: NomieImport): @click.version_option(version=__version__) @click.argument("inputfile") def main(inputfile): - data = nomie.get_data(inputfile) + data = nomie.get_data(inputfile, False) migrate(data) diff --git a/src/habitmove/nomie.py b/src/habitmove/nomie.py index 847edf6..bc6b7af 100644 --- a/src/habitmove/nomie.py +++ b/src/habitmove/nomie.py @@ -3,7 +3,7 @@ import json import re from click import secho, echo -from habitmove.nomiedata import Tracker, Event, Activity, NomieImport +from habitmove.nomiedata import Tracker, Event, Activity, ImportData def load_file(filename): @@ -26,7 +26,7 @@ def confirmation_question(question, default_no=True): # display stats and ask user to confirm if they seem okay -def verify_continue(data: NomieImport): +def verify_continue(data: ImportData): trackers = "" for t in data.trackers: trackers += t.label + ", " @@ -94,14 +94,14 @@ def get_activities_for_event(event_text, tracker_list): # return the data belonging to nomie -def get_data(file, interactive=True): +def get_data(file: str, interactive: bool = True): raw_data = load_file(file) nomie_version = raw_data["nomie"]["number"] tracker_list = get_trackers(raw_data["trackers"]) event_list = get_events(raw_data["events"], tracker_list) - data = NomieImport(nomie_version, tracker_list, event_list) + data = ImportData(nomie_version, tracker_list, event_list) if interactive: verify_continue(data) diff --git a/src/habitmove/nomiedata.py b/src/habitmove/nomiedata.py index 60d2670..16c6c25 100644 --- a/src/habitmove/nomiedata.py +++ b/src/habitmove/nomiedata.py @@ -59,7 +59,7 @@ class Event: @dataclass(frozen=True) -class NomieImport: +class ImportData: version: str trackers: list[Tracker] events: list[Event] From 5d8bde959eec1c70ad6bdc40393b3ad8e5fa4b2f Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Thu, 16 Dec 2021 13:11:49 +0100 Subject: [PATCH 04/12] Add tested Parser Interface --- src/habitmove/parser.py | 17 +++++++++++++---- tests/test_parser.py | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 tests/test_parser.py diff --git a/src/habitmove/parser.py b/src/habitmove/parser.py index f33499e..61141dd 100644 --- a/src/habitmove/parser.py +++ b/src/habitmove/parser.py @@ -1,11 +1,16 @@ -from habitmove.nomiedata import Event, Tracker +from __future__ import annotations + +from habitmove.nomiedata import Event, ImportData, Tracker class Parser: - def __init__(self, path: str, filename: str) -> None: + def parse(self, path: str, filename: str) -> ImportData: """Load in a data set""" - self.path = path - self.filename = filename + raise NotImplementedError + + def extract_version(self) -> str: + """Extract import dataset version from the data set""" + raise NotImplementedError def extract_trackers(self) -> list[Tracker]: """Extract trackers from the data set""" @@ -14,3 +19,7 @@ class Parser: def extract_events(self) -> list[Event]: """Extract events from the data set""" raise NotImplementedError + + +class NomieParser(Parser): + pass diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..55cef76 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,19 @@ +from habitmove.parser import Parser, NomieParser + + +def test_parser_interface_exists(): + sut = Parser() + assert type(sut) == Parser + + +def test_parser_interface_contains_methods(): + sut = Parser() + assert sut.__getattribute__("parse") != None + assert sut.__getattribute__("extract_version") != None + assert sut.__getattribute__("extract_trackers") != None + assert sut.__getattribute__("extract_events") != None + + +def test_nomie_parser_exists(): + sut = NomieParser() + assert type(sut) == NomieParser From d525d7c5841c48380f9d869027ee4fd633ca7aa7 Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Thu, 16 Dec 2021 13:12:27 +0100 Subject: [PATCH 05/12] Add end-to-end test for nomie->loop --- tests/data/loop/output.db | Bin 0 -> 122880 bytes tests/data/nomie/input.json | 1 + tests/test_cli.py | 35 +++++++++++++++++++++++++++++++++-- 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 tests/data/loop/output.db create mode 100644 tests/data/nomie/input.json diff --git a/tests/data/loop/output.db b/tests/data/loop/output.db new file mode 100644 index 0000000000000000000000000000000000000000..98d04c7908f0b01db45d4c5f4d010e15631e9081 GIT binary patch literal 122880 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 Date: Fri, 24 Dec 2021 21:52:57 +0100 Subject: [PATCH 06/12] Add signature checking --- tests/test_parser.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_parser.py b/tests/test_parser.py index 55cef76..a9f52b2 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,4 +1,5 @@ from habitmove.parser import Parser, NomieParser +from inspect import signature def test_parser_interface_exists(): @@ -9,11 +10,37 @@ def test_parser_interface_exists(): def test_parser_interface_contains_methods(): sut = Parser() assert sut.__getattribute__("parse") != None + assert sut.__getattribute__("load_file") != None assert sut.__getattribute__("extract_version") != None assert sut.__getattribute__("extract_trackers") != None assert sut.__getattribute__("extract_events") != None +def test_parser_load_file_returns_parser(): + sut = Parser().load_file + assert signature(sut).return_annotation == "Parser" + + +def test_parser_parse_returns_Import_Data(): + sut = Parser().parse + assert signature(sut).return_annotation == "ImportData" + + +def test_parser_version_returns_String(): + sut = Parser().extract_version + assert signature(sut).return_annotation == "str" + + +def test_parser_extract_trackers_returns_tracker_list(): + sut = Parser().extract_trackers + assert signature(sut).return_annotation == "list[Tracker]" + + +def test_parser_extract_events_returns_event_list(): + sut = Parser().extract_events + assert signature(sut).return_annotation == "list[Event]" + + def test_nomie_parser_exists(): sut = NomieParser() assert type(sut) == NomieParser From bb4b85851e75ebe0cdcba102f91a98841280454a Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Fri, 24 Dec 2021 22:34:49 +0100 Subject: [PATCH 07/12] Begin nomie parser creation --- src/habitmove/nomie_parser.py | 10 ++++++++++ src/habitmove/parser.py | 17 ++++++++++++----- tests/test_nomie_parser.py | 18 ++++++++++++++++++ tests/test_parser.py | 13 ++++--------- 4 files changed, 44 insertions(+), 14 deletions(-) create mode 100644 src/habitmove/nomie_parser.py create mode 100644 tests/test_nomie_parser.py diff --git a/src/habitmove/nomie_parser.py b/src/habitmove/nomie_parser.py new file mode 100644 index 0000000..efa02d2 --- /dev/null +++ b/src/habitmove/nomie_parser.py @@ -0,0 +1,10 @@ +from __future__ import annotations +from json import loads as jsonloads + +from habitmove.parser import Parser + + +class NomieParser(Parser): + def __init__(self, data="{}") -> None: + """Load a data set and prepare parser data""" + self.data = jsonloads(data) diff --git a/src/habitmove/parser.py b/src/habitmove/parser.py index 61141dd..008ac94 100644 --- a/src/habitmove/parser.py +++ b/src/habitmove/parser.py @@ -1,11 +1,22 @@ from __future__ import annotations +from pathlib import Path from habitmove.nomiedata import Event, ImportData, Tracker class Parser: - def parse(self, path: str, filename: str) -> ImportData: + def __init__(self, data="") -> None: + """Load a data set and prepare parser data""" + self.data = data + + @classmethod + def from_file(cls, path: str) -> Parser: """Load in a data set""" + txt = Path(path).read_text() + return cls(data=txt) + + def parse(self) -> ImportData: + """Extract all data from a data set""" raise NotImplementedError def extract_version(self) -> str: @@ -19,7 +30,3 @@ class Parser: def extract_events(self) -> list[Event]: """Extract events from the data set""" raise NotImplementedError - - -class NomieParser(Parser): - pass diff --git a/tests/test_nomie_parser.py b/tests/test_nomie_parser.py new file mode 100644 index 0000000..0ae6fd3 --- /dev/null +++ b/tests/test_nomie_parser.py @@ -0,0 +1,18 @@ +from habitmove.nomie_parser import NomieParser +import json +import pytest + + +def test_nomie_parser_exists(): + sut = NomieParser() + assert type(sut) == NomieParser + + +def test_nomie_Parser_errors_on_invalid_data(): + with pytest.raises(json.decoder.JSONDecodeError): + NomieParser(data="invalid_test_data") + + +def test_nomie_Parser_saves_data(): + sut = NomieParser(data='{"test": "entry"}') + assert sut.data == {"test": "entry"} diff --git a/tests/test_parser.py b/tests/test_parser.py index a9f52b2..3356025 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,4 +1,4 @@ -from habitmove.parser import Parser, NomieParser +from habitmove.parser import Parser from inspect import signature @@ -10,14 +10,14 @@ def test_parser_interface_exists(): def test_parser_interface_contains_methods(): sut = Parser() assert sut.__getattribute__("parse") != None - assert sut.__getattribute__("load_file") != None + assert sut.__getattribute__("from_file") != None assert sut.__getattribute__("extract_version") != None assert sut.__getattribute__("extract_trackers") != None assert sut.__getattribute__("extract_events") != None -def test_parser_load_file_returns_parser(): - sut = Parser().load_file +def test_parser_from_file_returns_parser(): + sut = Parser().from_file assert signature(sut).return_annotation == "Parser" @@ -39,8 +39,3 @@ def test_parser_extract_trackers_returns_tracker_list(): def test_parser_extract_events_returns_event_list(): sut = Parser().extract_events assert signature(sut).return_annotation == "list[Event]" - - -def test_nomie_parser_exists(): - sut = NomieParser() - assert type(sut) == NomieParser From 7134dc65d83d5d28de2743c25ae0672ed63b6311 Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Sat, 25 Dec 2021 10:27:38 +0100 Subject: [PATCH 08/12] Add version extraction to parser --- src/habitmove/nomie_parser.py | 3 +++ tests/test_nomie_parser.py | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/habitmove/nomie_parser.py b/src/habitmove/nomie_parser.py index efa02d2..1cfd482 100644 --- a/src/habitmove/nomie_parser.py +++ b/src/habitmove/nomie_parser.py @@ -8,3 +8,6 @@ class NomieParser(Parser): def __init__(self, data="{}") -> None: """Load a data set and prepare parser data""" self.data = jsonloads(data) + + def extract_version(self) -> str: + return self.data["nomie"]["number"] diff --git a/tests/test_nomie_parser.py b/tests/test_nomie_parser.py index 0ae6fd3..caafbb7 100644 --- a/tests/test_nomie_parser.py +++ b/tests/test_nomie_parser.py @@ -3,16 +3,26 @@ import json import pytest +@pytest.fixture +def sample_data(): + return '{ "nomie": { "number": "5.6.4", "created": "2021-08-26T08:15:36.898Z", "startDate": "2021-08-26T08:15:36.898Z", "endDate": "2021-08-26T08:15:36.898Z" }}' + + def test_nomie_parser_exists(): sut = NomieParser() assert type(sut) == NomieParser -def test_nomie_Parser_errors_on_invalid_data(): +def test_nomie_parser_errors_on_invalid_data(): with pytest.raises(json.decoder.JSONDecodeError): NomieParser(data="invalid_test_data") -def test_nomie_Parser_saves_data(): +def test_nomie_parser_saves_data(): sut = NomieParser(data='{"test": "entry"}') assert sut.data == {"test": "entry"} + + +def test_nomie_parser_extracts_version(sample_data): + sut = NomieParser(data=sample_data) + assert sut.extract_version() == "5.6.4" From fd4cd62636b78c2e83df1fc27d55df5b5154cc8b Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Wed, 5 Jan 2022 12:54:35 +0100 Subject: [PATCH 09/12] Add README for internal data representation --- README.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 31a8a2c..6f2fafb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # habit-migrate -Can take an export of nomie habits in json format and convert it to be importable in Loop Habit Tracker. +Can take an export of [nomie](https://nomie.app/) habits in json format and convert it to be importable in [Loop Habit Tracker](https://loophabits.org/). Confirmed working for nomie version 5.6.4 and Loop Habit Tracker version 2.0.2 and 2.0.3. Presumably works for other nomie 5.x versions and other Loop 2.x versions as well, @@ -32,3 +32,22 @@ In the future there might be an easier road to using this package but that's the The package can also be used as a library to load nomie data or move data into Loop Habit Tracker. +## Internal representation + +Internally, the data gets represented within three concepts: +Events, Activities and Trackers. + +Events are simple entries or logs of, basically, anything and represent *qualitative* data. +At their most basic, they only describe 'something' at a certain point in time. +For that, they have to contain a time and they may contain prose text (i.e. an arbitrary text string). +Additionally, an event can contain a list of one or more activities. + +Activities are the changes to whatever is tracked *quantitatively*. +They always belong to an event and thus the moment in time the event took place. +They might even be the only interesting thing that took place in the event, +but not necessarily. +Lastly, they contain a single tracker which they belong to. + +Trackers are the meta-data of whatever is being tracked quantitatively through activities. +They define a name, label, scores, descriptions, reminders and so on. +All data being imported is transformed into this model and output from it again. From b0f8c48e9986a4cdd1a1224be2c5f98be6b5ebad Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Wed, 5 Jan 2022 12:55:30 +0100 Subject: [PATCH 10/12] Prepare pypi release Added License, repository information and extended README. Added installation instructions to README. Added testing instructions to README. --- CHANGELOG.md | 8 +++++++- README.md | 52 +++++++++++++++++++++++++++++++++++++++----------- pyproject.toml | 5 ++++- 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7adcaa9..a03a334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project tries to adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] + + +## [0.4.1] - 2022-01-05 + +### Added + +* Added pypi release publication ### Changed diff --git a/README.md b/README.md index 31a8a2c..d989a1e 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,34 @@ -# habit-migrate +# habitmove -Can take an export of nomie habits in json format and convert it to be importable in Loop Habit Tracker. +Takes habit in one habit-tracking application and transforms them ready to use for another. + +Currently can take an export of nomie habits in json format and convert it to be importable in Loop Habit Tracker. +Plans for reverse migration are on the roadmap, and ultimately this tool ideally understands more and more habit formats to prevent app lock-in. Confirmed working for nomie version 5.6.4 and Loop Habit Tracker version 2.0.2 and 2.0.3. Presumably works for other nomie 5.x versions and other Loop 2.x versions as well, but that is untested. +## Installation + +Installation can be accomplished through *pip*: + +```bash +pip install habitmove +``` + +Requirements: + +`habitmove` requires at least Python 3.7. +It has only been tested on GNU/Linux (amd64) though it should work on other platforms. + ## Usage -Run as a commandline utility habit migrate currently takes a single argument, the nomie database `.json` file. -The output as importable Loop Habit Tracker database will be written to `output.db` in present working directory. +Run as a cli utility `habitmove` currently takes a single argument: the nomie database `.json` file to import habits from. + +Invoked like: `habitmove nomie-export.json`. + +The output as a Loop Habit Tracker database will be written to `output.db` in the present working directory. Can also take an existing Loop Habit database (exported from the application), and add the nomie exported habits and checkmarks to it. @@ -18,17 +37,28 @@ it will not (should not™️) overwrite anything. If there are any duplicated habits however, it will add duplications of the existing repetitions into the database. -Invoked like: `python run.py nomie-export.json`. -Note, however, that -- until a packaged version is released -- you will need to have some packages in your environment. -If you wish to run it un-packaged, install [poetry](https://python-poetry.org/) and let it do all dependency management by doing: +## Development -``` +To enable easy development on the app, +install [poetry](https://python-poetry.org/) and let it do all dependency management for you by doing: + +```bash poetry install poetry run habitmove ``` -In the future there might be an easier road to using this package but that's the way it is for now. +To see a set up more closely resembling the final cli environment, +with its libraries loaded as environmental dependencies enter the poetry shell: -The package can also be used as a library to load nomie data -or move data into Loop Habit Tracker. +```bash +poetry shell +``` +The package can eventually also be used as a library to load nomie data to work with in Python, +or to move data into Loop Habit Tracker. +Take a look at the `Parser` and `Transformer` interfaces respectively. + +To run tests for the app, simply invoke `pytest` through `poetry run pytest` or from within the `poetry shell`. +To run larger scale test automation, make sure you habe nox installed and run `poetry run ` or again through the shell. + +You can exclude integration tests that take longer and inspect the complete database output of the program through the parameters `-m "not e2e"` for both `pytest` and `nox` (which also does it automatically). diff --git a/pyproject.toml b/pyproject.toml index f90ff08..c0716f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,10 @@ [tool.poetry] name = "habitmove" -version = "0.4.0" +version = "0.4.1" description = "migrate nomie data to loop habits tracker" +license="GPL-3.0-only" +readme="README.md" +repository="https://git.martyoeh.me/Marty/habit-migrate" authors = ["Marty Oehme "] packages = [ { include = "habitmove", from = "src"}, From 03dd1a485d7dc8993ab79edff5da3456d60f08ba Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Tue, 18 Jan 2022 18:08:24 +0100 Subject: [PATCH 11/12] Add continuous integration pipeline Added basic continuous integration tests to run on any push: On main branch, the python program is built. On tagged commit, a gitea release is created. Fixed first detected build pipeline issues: Fixing mypy library stubs missing for some imported libraries. Fixed two small typing errors for Repetitions. The steps run on basic python containers, onto which the ci steps simply install poetry. This takes a little more processing time during pipeline running (~16s per step), but also gives a lot of flexibility in container usage. Added script which assists in creating an automatic release by extracting the current version and newest changes from the semantic changelog. This is then used in the gitea release preparation as title and content of the release message. The files built in the dist directory by poetry will be attached. --- .woodpecker.yml | 75 ++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 2 +- pyproject.toml | 10 +++++ src/habitmove/__init__.py | 2 +- src/habitmove/repetitions.py | 7 +++- tools/extract-changelog.py | 56 +++++++++++++++++++++++++++ 6 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 .woodpecker.yml create mode 100644 tools/extract-changelog.py diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..e50afec --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,75 @@ +branches: main + +pipeline: + code_lint: + image: python + commands: + - pip install poetry + - poetry install + - pip install black + - echo "----------------- running lint ------------------" + - python --version && poetry --version && black --version + - poetry run black . + + unit_tests: + image: thekevjames/nox + commands: + - pip install poetry + - poetry install + - echo "----------------- running tests ------------------" + - python --version && poetry --version && nox --version + - poetry run nox + + static_analysis: + image: python + commands: + - pip install poetry + - poetry install + - pip install mypy + - echo "----------------- running analysis ------------------" + - python --version && poetry --version && mypy --version + - poetry run mypy . + + build_dist: + image: python + commands: + - pip install poetry + - poetry install + - echo "----------------- running analysis ------------------" + - python --version && poetry --version + - poetry build + when: + branch: main + + release_prep: + image: python + commands: + - echo "----------------- preparing release ------------------" + - python tools/extract-changelog.py + + gitea_release: + image: plugins/gitea-release + settings: + api_key: + from_secret: gitea_release_token + base_url: https://git.martyoeh.me + files: dist/* + title: NEWEST_VERSION.md + note: NEWEST_CHANGES.md + when: + event: tag + tag: v* + + notify_matrix: + image: plugins/matrix + settings: + homeserver: https://matrix.org + roomid: + from_secret: matrix_roomid + userid: + from_secret: matrix_userid + accesstoken: + from_secret: matrix_token + when: + status: [ success, failure ] + diff --git a/CHANGELOG.md b/CHANGELOG.md index a03a334..723aa00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project tries to adhere to [Semantic Versioning](https://semver.org/spe * Compatible with Python stretching back to version 3.7 -## [0.4] - 2021-12-06 +## [0.4.0] - 2021-12-06 ### Added diff --git a/pyproject.toml b/pyproject.toml index c0716f4..468c0db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,3 +38,13 @@ show_missing = true [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[[tool.mypy.overrides]] +module = [ + "click", + "click.testing", + "pytest", + "nox", + "importlib-metadata" +] +ignore_missing_imports = true diff --git a/src/habitmove/__init__.py b/src/habitmove/__init__.py index fcbc880..6c60766 100644 --- a/src/habitmove/__init__.py +++ b/src/habitmove/__init__.py @@ -4,6 +4,6 @@ import sys try: from importlib.metadata import version as metadata_version except ImportError: - from importlib_metadata import version as metadata_version + from importlib_metadata import version as metadata_version # type: ignore __version__ = str(metadata_version(__name__)) diff --git a/src/habitmove/repetitions.py b/src/habitmove/repetitions.py index 0d1ffa9..63ce416 100644 --- a/src/habitmove/repetitions.py +++ b/src/habitmove/repetitions.py @@ -55,10 +55,11 @@ def habit_list_add_ids(c: sqlite3.Cursor, habitlist: list[Habit]) -> dict[int, H :return habit_id_dict: The habit collection as a dict with the keys consisting of the habit's sqlite database ID. """ - with_id = {} + with_id: dict[int, Habit] = {} for h in habitlist: sql_id = fetch_habit_id(c, h.uuid or "") - with_id[sql_id] = h + if sql_id is not None: + with_id[sql_id] = h return with_id @@ -74,6 +75,8 @@ def fetch_habit_id(cursor: sqlite3.Cursor, uuid: str) -> Optional[int]: if id is not None: return id[0] + return None + def add_to_database( cursor: sqlite3.Cursor, habits: dict[int, Habit], repetition: Repetition diff --git a/tools/extract-changelog.py b/tools/extract-changelog.py new file mode 100644 index 0000000..e14de09 --- /dev/null +++ b/tools/extract-changelog.py @@ -0,0 +1,56 @@ +import re + +## Extracts the version and newest changes from a semantic changelog. +# +# Important, it only works with three-parted version numbers +# a-la 1.2.3 or 313.01.1888 -- needs \d.\d.\d to work. +# +# The version number and changeset will be put in `NEWEST_VERSION.md` +# and `NEWEST_CHANGES.md` respectively, for further use in releases. +OUTPUT_FILE_VERSION = "NEWEST_VERSION.md" +OUTPUT_FILE_CHANGES = "NEWEST_CHANGES.md" + + +def getVersion(file): + for line in file: + m = re.match(r"^## \[(\d+\.\d+\.\d+)\]", line) + if m and m.group(1): + return m.group(1) + + +def getSection(file): + inRecordingMode = False + for line in file: + if not inRecordingMode: + if re.match(r"^## \[\d+\.\d+\.\d+\]", line): + inRecordingMode = True + elif re.match(r"^## \[\d+\.\d+\.\d+\]", line): + inRecordingMode = False + break + elif re.match(r"^$", line): + pass + else: + yield line + + +def toFile(fname, content): + file = open(fname, "w") + file.write(content) + file.close() + + +with open("CHANGELOG.md") as file: + title = getVersion(file) + print(title) + toFile(OUTPUT_FILE_VERSION, title) + +with open("CHANGELOG.md") as file: + newest_changes_gen = getSection(file) + newest_changes = "" + for line in newest_changes_gen: + newest_changes += line + print("[Extracted Changelog]") + print(newest_changes) + toFile(OUTPUT_FILE_CHANGES, newest_changes) + +file.close() From 6df22f8cd5f001bf5523c536e03e460702033061 Mon Sep 17 00:00:00 2001 From: Marty Oehme Date: Wed, 19 Jan 2022 16:53:26 +0100 Subject: [PATCH 12/12] Add pypi release automation --- .woodpecker.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.woodpecker.yml b/.woodpecker.yml index e50afec..d17c73e 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -60,6 +60,17 @@ pipeline: event: tag tag: v* + pypi_release: + image: python + commands: + - pip install poetry + - poetry install + - echo "----------------- publishing to pypi ------------------" + - poetry publish --username "$PYPI_USERNAME" --password "$PYPI_PASSWORD" + when: + event: tag + tag: v* + notify_matrix: image: plugins/matrix settings: