Compare commits
No commits in common. "fd4cd62636b78c2e83df1fc27d55df5b5154cc8b" and "2bbb594d62864883ead91d4710e09fd65e5e2855" have entirely different histories.
fd4cd62636
...
2bbb594d62
14 changed files with 18 additions and 169 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -1,5 +1,5 @@
|
||||||
/data/
|
data/
|
||||||
/output.db
|
output.db
|
||||||
|
|
||||||
# Created by https://www.toptal.com/developers/gitignore/api/vim,linux,python,pandas
|
# 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
|
# Edit at https://www.toptal.com/developers/gitignore?templates=vim,linux,python,pandas
|
||||||
|
|
21
README.md
21
README.md
|
@ -1,6 +1,6 @@
|
||||||
# habit-migrate
|
# habit-migrate
|
||||||
|
|
||||||
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/).
|
Can take an export of nomie habits in json format and convert it to be importable in Loop Habit Tracker.
|
||||||
|
|
||||||
Confirmed working for nomie version 5.6.4 and Loop Habit Tracker version 2.0.2 and 2.0.3.
|
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,
|
Presumably works for other nomie 5.x versions and other Loop 2.x versions as well,
|
||||||
|
@ -32,22 +32,3 @@ 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
|
The package can also be used as a library to load nomie data
|
||||||
or move data into Loop Habit Tracker.
|
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.
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import nox
|
||||||
|
|
||||||
@nox.session(python=["3.7", "3.8", "3.9"])
|
@nox.session(python=["3.7", "3.8", "3.9"])
|
||||||
def tests(session):
|
def tests(session):
|
||||||
args = session.posargs or ["--cov", "-m", "not e2e"]
|
args = session.posargs or ["--cov"]
|
||||||
session.run("poetry", "install", external=True)
|
session.run("poetry", "install", external=True)
|
||||||
session.run("pytest", *args)
|
session.run("pytest", *args)
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -3,13 +3,13 @@ import habitmove.schema as schema
|
||||||
import habitmove.habits as habits
|
import habitmove.habits as habits
|
||||||
import habitmove.repetitions as rep
|
import habitmove.repetitions as rep
|
||||||
import habitmove.nomie as nomie
|
import habitmove.nomie as nomie
|
||||||
from habitmove.nomiedata import ImportData
|
from habitmove.nomiedata import NomieImport
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from . import __version__
|
from . import __version__
|
||||||
|
|
||||||
|
|
||||||
def migrate(data: ImportData):
|
def migrate(data: NomieImport):
|
||||||
db = schema.migrate("output.db")
|
db = schema.migrate("output.db")
|
||||||
if not db:
|
if not db:
|
||||||
raise ConnectionError
|
raise ConnectionError
|
||||||
|
@ -29,7 +29,7 @@ def migrate(data: ImportData):
|
||||||
@click.version_option(version=__version__)
|
@click.version_option(version=__version__)
|
||||||
@click.argument("inputfile")
|
@click.argument("inputfile")
|
||||||
def main(inputfile):
|
def main(inputfile):
|
||||||
data = nomie.get_data(inputfile, False)
|
data = nomie.get_data(inputfile)
|
||||||
migrate(data)
|
migrate(data)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from click import secho, echo
|
from click import secho, echo
|
||||||
from habitmove.nomiedata import Tracker, Event, Activity, ImportData
|
from habitmove.nomiedata import Tracker, Event, Activity, NomieImport
|
||||||
|
|
||||||
|
|
||||||
def load_file(filename):
|
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
|
# display stats and ask user to confirm if they seem okay
|
||||||
def verify_continue(data: ImportData):
|
def verify_continue(data: NomieImport):
|
||||||
trackers = ""
|
trackers = ""
|
||||||
for t in data.trackers:
|
for t in data.trackers:
|
||||||
trackers += t.label + ", "
|
trackers += t.label + ", "
|
||||||
|
@ -94,14 +94,14 @@ def get_activities_for_event(event_text, tracker_list):
|
||||||
|
|
||||||
|
|
||||||
# return the data belonging to nomie
|
# return the data belonging to nomie
|
||||||
def get_data(file: str, interactive: bool = True):
|
def get_data(file, interactive=True):
|
||||||
raw_data = load_file(file)
|
raw_data = load_file(file)
|
||||||
nomie_version = raw_data["nomie"]["number"]
|
nomie_version = raw_data["nomie"]["number"]
|
||||||
|
|
||||||
tracker_list = get_trackers(raw_data["trackers"])
|
tracker_list = get_trackers(raw_data["trackers"])
|
||||||
event_list = get_events(raw_data["events"], tracker_list)
|
event_list = get_events(raw_data["events"], tracker_list)
|
||||||
|
|
||||||
data = ImportData(nomie_version, tracker_list, event_list)
|
data = NomieImport(nomie_version, tracker_list, event_list)
|
||||||
if interactive:
|
if interactive:
|
||||||
verify_continue(data)
|
verify_continue(data)
|
||||||
|
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
||||||
def extract_version(self) -> str:
|
|
||||||
return self.data["nomie"]["number"]
|
|
|
@ -59,7 +59,7 @@ class Event:
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ImportData:
|
class NomieImport:
|
||||||
version: str
|
version: str
|
||||||
trackers: list[Tracker]
|
trackers: list[Tracker]
|
||||||
events: list[Event]
|
events: list[Event]
|
||||||
|
|
|
@ -1,27 +1,11 @@
|
||||||
from __future__ import annotations
|
from habitmove.nomiedata import Event, Tracker
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from habitmove.nomiedata import Event, ImportData, Tracker
|
|
||||||
|
|
||||||
|
|
||||||
class Parser:
|
class Parser:
|
||||||
def __init__(self, data="") -> None:
|
def __init__(self, path: str, filename: str) -> 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"""
|
"""Load in a data set"""
|
||||||
txt = Path(path).read_text()
|
self.path = path
|
||||||
return cls(data=txt)
|
self.filename = filename
|
||||||
|
|
||||||
def parse(self) -> ImportData:
|
|
||||||
"""Extract all data from a data set"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def extract_version(self) -> str:
|
|
||||||
"""Extract import dataset version from the data set"""
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def extract_trackers(self) -> list[Tracker]:
|
def extract_trackers(self) -> list[Tracker]:
|
||||||
"""Extract trackers from the data set"""
|
"""Extract trackers from the data set"""
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
def pytest_configure(config):
|
|
||||||
config.addinivalue_line("markers", "e2e: mark as end to end test.")
|
|
Binary file not shown.
File diff suppressed because one or more lines are too long
|
@ -1,46 +1,15 @@
|
||||||
from click.testing import CliRunner
|
import click.testing
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
# for integration tests
|
|
||||||
from pathlib import Path
|
|
||||||
from shutil import copyfile
|
|
||||||
from subprocess import run
|
|
||||||
|
|
||||||
from habitmove import cli
|
from habitmove import cli
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def runner():
|
def runner():
|
||||||
return CliRunner()
|
return click.testing.CliRunner()
|
||||||
|
|
||||||
|
|
||||||
# Create an isolated environment to test the output file in
|
|
||||||
@pytest.fixture
|
|
||||||
def runner_with_nomie_input(tmp_path):
|
|
||||||
runner = CliRunner()
|
|
||||||
fname_input_data = Path("tests/data/nomie/input.json").resolve()
|
|
||||||
fname_target_data = Path("tests/data/loop/output.db").resolve()
|
|
||||||
with runner.isolated_filesystem(temp_dir=tmp_path):
|
|
||||||
copyfile(fname_input_data.resolve(), f"input.json")
|
|
||||||
copyfile(fname_target_data.resolve(), f"target")
|
|
||||||
yield runner
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_fails_without_file(runner):
|
def test_cli_fails_without_file(runner):
|
||||||
result = runner.invoke(cli.main)
|
result = runner.invoke(cli.main)
|
||||||
assert result.exit_code == 2
|
assert result.exit_code == 2
|
||||||
assert "Missing argument" in result.output
|
assert "Missing argument" in result.output
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.e2e
|
|
||||||
def test_produces_output_file(runner_with_nomie_input):
|
|
||||||
result = runner_with_nomie_input.invoke(cli.main, "input.json")
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert Path("output.db").exists()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.e2e
|
|
||||||
def test_produces_correct_output(runner_with_nomie_input):
|
|
||||||
runner_with_nomie_input.invoke(cli.main, "input.json")
|
|
||||||
result = run(["sqldiff", "output.db", "target"], capture_output=True)
|
|
||||||
assert result.stdout == b""
|
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
from habitmove.nomie_parser import NomieParser
|
|
||||||
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():
|
|
||||||
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"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_nomie_parser_extracts_version(sample_data):
|
|
||||||
sut = NomieParser(data=sample_data)
|
|
||||||
assert sut.extract_version() == "5.6.4"
|
|
|
@ -1,41 +0,0 @@
|
||||||
from habitmove.parser import Parser
|
|
||||||
from inspect import signature
|
|
||||||
|
|
||||||
|
|
||||||
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__("from_file") != None
|
|
||||||
assert sut.__getattribute__("extract_version") != None
|
|
||||||
assert sut.__getattribute__("extract_trackers") != None
|
|
||||||
assert sut.__getattribute__("extract_events") != None
|
|
||||||
|
|
||||||
|
|
||||||
def test_parser_from_file_returns_parser():
|
|
||||||
sut = Parser().from_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]"
|
|
Loading…
Reference in a new issue