From 137299d94f9242673ca40ca7674cf26560673e87 Mon Sep 17 00:00:00 2001 From: Dirk Doesburg Date: Thu, 23 Jun 2022 18:13:15 +0200 Subject: [PATCH 1/4] Add Black formatting and use github workflows This replaces Travis CI with a github workflow and gets rid of tox. It also adds Black formatting and configures flake8 for black compatibility. --- .github/workflows/linting-testing.yaml | 38 ++++++++++++++++++++++++++ .travis.yml | 30 -------------------- MANIFEST.in | 1 - pytest.ini | 3 -- requirements-dev.txt | 2 +- setup.cfg | 8 ++++++ tox.ini | 17 ------------ 7 files changed, 47 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/linting-testing.yaml delete mode 100644 .travis.yml delete mode 100644 pytest.ini delete mode 100644 tox.ini diff --git a/.github/workflows/linting-testing.yaml b/.github/workflows/linting-testing.yaml new file mode 100644 index 00000000..ffa0e97a --- /dev/null +++ b/.github/workflows/linting-testing.yaml @@ -0,0 +1,38 @@ +name: Linting and Testing + +on: + push: + branches: + - master + pull_request: + +jobs: + linting-testing: + name: Linting and Testing + strategy: + matrix: + version: ["3.5", "3.6", "3.7", "3.8", "3.9"] + runs-on: ubuntu-latest + steps: + + - name: Checkout the repository + uses: actions/checkout@v3 + + - name: Setup 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 -r requirements-dev.txt + + - name: Run black + run: black --check . + + - name: Run flake8 + run: flake8 --show-source watson/ tests/ scripts/ + + - name: Run pytest + run: pytest tests \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 88bee9a6..00000000 --- a/.travis.yml +++ /dev/null @@ -1,30 +0,0 @@ -language: python - -matrix: - include: - - python: 3.7 - os: linux - dist: xenial - env: TOXENV=flake8 - - python: 3.6 - os: linux - dist: trusty - env: TOXENV=py36 - - python: 3.7 - os: linux - dist: xenial - env: TOXENV=py37 - - python: 3.8 - os: linux - dist: xenial - env: TOXENV=py38 - - python: 3.9 - os: linux - dist: xenial - env: TOXENV=py39 - -install: - - pip install tox - -script: - - tox -e "${TOXENV}" diff --git a/MANIFEST.in b/MANIFEST.in index 277ccae5..6550a625 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,7 +6,6 @@ include mkdocs.yml include README.md include requirements-dev.txt include requirements.txt -include tox.ini include watson.completion include watson.zsh-completion diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 04a84cb1..00000000 --- a/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -markers = - datafiles: pytest-datafiles plugin marker. This avoids warning message. diff --git a/requirements-dev.txt b/requirements-dev.txt index d9db059d..3f7a2443 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -10,9 +10,9 @@ twine # Tests flake8 +black py pytest pytest-datafiles pytest-mock pytest-runner -tox diff --git a/setup.cfg b/setup.cfg index d662cb96..23afa26a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,3 +3,11 @@ universal=1 [aliases] test=pytest + +[tool:pytest] +markers = + datafiles: pytest-datafiles plugin marker. This avoids warning message. + +[flake8] +max-line-length = 88 +extend-ignore = E203 \ No newline at end of file diff --git a/tox.ini b/tox.ini deleted file mode 100644 index f500c5e2..00000000 --- a/tox.ini +++ /dev/null @@ -1,17 +0,0 @@ -[tox] -envlist = flake8,py35,py36,py37,py38,py39 -skip_missing_interpreters = True - -[testenv] -deps = - pytest - py - mock - pytest-datafiles - pytest-mock -commands = py.test -vs tests/ -usedevelop = True - -[testenv:flake8] -deps = flake8 -commands = flake8 --show-source watson/ tests/ scripts/ From 4259d68f0481f5182518dff2ee98d058709cae59 Mon Sep 17 00:00:00 2001 From: Dirk Doesburg Date: Thu, 23 Jun 2022 18:19:29 +0200 Subject: [PATCH 2/4] Refactor code to comply with Black style --- scripts/fuzzer.py | 15 +- scripts/gen-cli-docs.py | 67 +- setup.py | 40 +- tests/__init__.py | 15 +- tests/conftest.py | 2 +- tests/test_autocompletion.py | 8 +- tests/test_cli.py | 192 ++--- tests/test_config.py | 113 ++- tests/test_utils.py | 248 +++--- tests/test_watson.py | 635 +++++++------- watson/__init__.py | 2 +- watson/autocompletion.py | 6 +- watson/cli.py | 1544 ++++++++++++++++++++++------------ watson/config.py | 16 +- watson/frames.py | 45 +- watson/fullmoon.py | 1450 ++++++++++++++++++++++++++----- watson/utils.py | 205 +++-- watson/watson.py | 268 +++--- 18 files changed, 3205 insertions(+), 1666 deletions(-) diff --git a/scripts/fuzzer.py b/scripts/fuzzer.py index 0b297938..687a3634 100755 --- a/scripts/fuzzer.py +++ b/scripts/fuzzer.py @@ -7,15 +7,13 @@ from watson import Watson -if not os.environ.get('WATSON_DIR'): +if not os.environ.get("WATSON_DIR"): sys.exit( "This script will corrupt Watson's data, please set the WATSON_DIR " "environment variable to safely use it for development purpose." ) -watson = Watson(config_dir=os.environ.get('WATSON_DIR'), - frames=None, - current=None) +watson = Watson(config_dir=os.environ.get("WATSON_DIR"), frames=None, current=None) projects = [ ("apollo11", ["reactor", "module", "wheels", "steering", "brakes"]), @@ -26,13 +24,14 @@ now = arrow.now() -for date in arrow.Arrow.range('day', now.shift(months=-1), now): +for date in arrow.Arrow.range("day", now.shift(months=-1), now): if date.weekday() in (5, 6): # Weekend \o/ continue - start = date.replace(hour=9, minute=random.randint(0, 59)) \ - .shift(seconds=random.randint(0, 59)) + start = date.replace(hour=9, minute=random.randint(0, 59)).shift( + seconds=random.randint(0, 59) + ) while start.hour < random.randint(16, 19): project, tags = random.choice(projects) @@ -40,7 +39,7 @@ project, start, start.shift(seconds=random.randint(60, 4 * 60 * 60)), - tags=random.sample(tags, random.randint(0, len(tags))) + tags=random.sample(tags, random.randint(0, len(tags))), ) start = frame.stop.shift(seconds=random.randint(0, 1 * 60 * 60)) diff --git a/scripts/gen-cli-docs.py b/scripts/gen-cli-docs.py index 65ad9b79..06d603bd 100755 --- a/scripts/gen-cli-docs.py +++ b/scripts/gen-cli-docs.py @@ -5,44 +5,43 @@ from click.core import Command, Context from click.formatting import HelpFormatter from watson import cli as watson_cli + # from watson import watson class MarkdownFormatter(HelpFormatter): - def write_heading(self, heading): """Writes a heading into the buffer.""" - self.write('### {}\n'.format(heading)) + self.write("### {}\n".format(heading)) - def write_usage(self, prog, args='', prefix='Usage: '): + def write_usage(self, prog, args="", prefix="Usage: "): """Writes a usage line into the buffer. :param prog: the program name. :param args: whitespace separated list of arguments. :param prefix: the prefix for the first line. """ - self.write('```bash\n{} {} {}\n```\n'.format(prefix, prog, args)) + self.write("```bash\n{} {} {}\n```\n".format(prefix, prog, args)) def write_text(self, text): - """Writes re-indented text into the buffer. - """ + """Writes re-indented text into the buffer.""" should_indent = False rows = [] - for row in text.split('\n'): + for row in text.split("\n"): if should_indent: - row = ' {}'.format(row) + row = " {}".format(row) - if '\b' in row: - row = row.replace('\b', '', 1) + if "\b" in row: + row = row.replace("\b", "", 1) should_indent = True elif not len(row.strip()): should_indent = False rows.append(row) - self.write("{}\n".format('\n'.join(rows))) + self.write("{}\n".format("\n".join(rows))) def write_dl(self, rows, **kwargs): """Writes a definition list into the buffer. This is how options @@ -50,22 +49,21 @@ def write_dl(self, rows, **kwargs): :param rows: a list of two item tuples for the terms and values. """ rows = list(rows) - self.write('\n') + self.write("\n") - self.write('Flag | Help\n') - self.write('-----|-----\n') + self.write("Flag | Help\n") + self.write("-----|-----\n") for row in rows: - self.write('`{}` | {}\n'.format(*row)) - self.write('\n') + self.write("`{}` | {}\n".format(*row)) + self.write("\n") class MkdocsContext(Context): - @property def command_path(self): # Not so proud of it - return 'watson {}'.format(self.command.name) + return "watson {}".format(self.command.name) def make_formatter(self): return MarkdownFormatter() @@ -84,18 +82,19 @@ def is_click_command(obj): return True return False - content = '\n'.join(( - "", - "", - "# Commands", - "", - )) + content = "\n".join( + ( + "", + "", + "# Commands", + "", + ) + ) # Iterate over commands to build docs for cmd_name, cmd in inspect.getmembers(watson_cli, is_click_command): @@ -106,14 +105,14 @@ def is_click_command(obj): # Each command is a section content += "## `{}`\n\n".format(cmd_name) - content += ''.join(formatter.buffer) + content += "".join(formatter.buffer) # Write the commands documentation file - with open(rowsput, 'w') as f: + with open(rowsput, "w") as f: f.write(content) -if __name__ == '__main__': +if __name__ == "__main__": - commands_md = 'docs/user-guide/commands.md' + commands_md = "docs/user-guide/commands.md" main(commands_md) diff --git a/setup.py b/setup.py index 22e16370..ffd9d111 100644 --- a/setup.py +++ b/setup.py @@ -5,16 +5,16 @@ from setuptools import setup -with open('README.rst') as f: +with open("README.rst") as f: readme = f.read() # read package meta-data from version.py pkg = {} -mod = join('watson', 'version.py') -exec(compile(open(mod).read(), mod, 'exec'), {}, pkg) +mod = join("watson", "version.py") +exec(compile(open(mod).read(), mod, "exec"), {}, pkg) -def parse_requirements(requirements, ignore=('setuptools',)): +def parse_requirements(requirements, ignore=("setuptools",)): """Read dependencies from requirements file (with version numbers if any) Note: this implementation does not support requirements files with extra @@ -24,10 +24,10 @@ def parse_requirements(requirements, ignore=('setuptools',)): packages = set() for line in f: line = line.strip() - if line.startswith(('#', '-r', '--')): + if line.startswith(("#", "-r", "--")): continue - if '#egg=' in line: - line = line.split('#egg=')[1] + if "#egg=" in line: + line = line.split("#egg=")[1] pkg = line.strip() if pkg not in ignore: packages.add(pkg) @@ -35,21 +35,21 @@ def parse_requirements(requirements, ignore=('setuptools',)): setup( - name='td-watson', - version=pkg['version'], - description='A wonderful CLI to track your time!', + name="td-watson", + version=pkg["version"], + description="A wonderful CLI to track your time!", url="https://github.com/TailorDev/Watson", - packages=['watson'], - author='TailorDev', - author_email='contact@tailordev.fr', - license='MIT', + packages=["watson"], + author="TailorDev", + author_email="contact@tailordev.fr", + license="MIT", long_description=readme, - install_requires=parse_requirements('requirements.txt'), - python_requires='>=3.6', - tests_require=parse_requirements('requirements-dev.txt'), + install_requires=parse_requirements("requirements.txt"), + python_requires=">=3.6", + tests_require=parse_requirements("requirements-dev.txt"), entry_points={ - 'console_scripts': [ - 'watson = watson.__main__:cli', + "console_scripts": [ + "watson = watson.__main__:cli", ] }, classifiers=[ @@ -73,5 +73,5 @@ def parse_requirements(requirements, ignore=('setuptools',)): "Topic :: Office/Business", "Topic :: Utilities", ], - keywords='watson time-tracking time tracking monitoring report', + keywords="watson time-tracking time tracking monitoring report", ) diff --git a/tests/__init__.py b/tests/__init__.py index 2c5f80ea..bbdd76c5 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -8,23 +8,18 @@ import py -TEST_FIXTURE_DIR = py.path.local( - os.path.dirname( - os.path.realpath(__file__) - ) - ) / 'resources' +TEST_FIXTURE_DIR = ( + py.path.local(os.path.dirname(os.path.realpath(__file__))) / "resources" +) def mock_datetime(dt, dt_module): - class DateTimeMeta(type): - @classmethod def __instancecheck__(mcs, obj): return isinstance(obj, datetime.datetime) class BaseMockedDateTime(datetime.datetime): - @classmethod def now(cls, tz=None): return dt.replace(tzinfo=tz) @@ -37,9 +32,9 @@ def utcnow(cls): def today(cls): return dt - MockedDateTime = DateTimeMeta('datetime', (BaseMockedDateTime,), {}) + MockedDateTime = DateTimeMeta("datetime", (BaseMockedDateTime,), {}) - return mock.patch.object(dt_module, 'datetime', MockedDateTime) + return mock.patch.object(dt_module, "datetime", MockedDateTime) def mock_read(content): diff --git a/tests/conftest.py b/tests/conftest.py index 6f21a4bf..f8293602 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,7 @@ @pytest.fixture def config_dir(tmpdir): - return str(tmpdir.mkdir('config')) + return str(tmpdir.mkdir("config")) @pytest.fixture diff --git a/tests/test_autocompletion.py b/tests/test_autocompletion.py index fb5b3ad8..bc76df3d 100644 --- a/tests/test_autocompletion.py +++ b/tests/test_autocompletion.py @@ -42,9 +42,7 @@ (get_tags, None, []), ], ) -def test_if_returned_values_are_distinct( - watson_df, func_to_test, rename_type, args -): +def test_if_returned_values_are_distinct(watson_df, func_to_test, rename_type, args): ctx = ClickContext(obj=watson_df, params={"rename_type": rename_type}) prefix = "" ret_list = list(func_to_test(ctx, args, prefix)) @@ -89,9 +87,7 @@ def test_if_empty_prefix_returns_everything( (get_tags, None, []), ], ) -def test_completion_of_nonexisting_prefix( - watson_df, func_to_test, rename_type, args -): +def test_completion_of_nonexisting_prefix(watson_df, func_to_test, rename_type, args): ctx = ClickContext(obj=watson_df, params={"rename_type": rename_type}) prefix = "NOT-EXISTING-PREFIX" ret_list = list(func_to_test(ctx, args, prefix)) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2a1b3a52..5404f04a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -11,204 +11,198 @@ # Not all ISO-8601 compliant strings are recognized by arrow.get(str) VALID_DATES_DATA = [ - ('2018', '2018-01-01 00:00:00'), # years - ('2018-04', '2018-04-01 00:00:00'), # calendar dates - ('2018-04-10', '2018-04-10 00:00:00'), - ('2018/04/10', '2018-04-10 00:00:00'), - ('2018.04.10', '2018-04-10 00:00:00'), - ('2018-4-10', '2018-04-10 00:00:00'), - ('2018/4/10', '2018-04-10 00:00:00'), - ('2018.4.10', '2018-04-10 00:00:00'), - ('20180410', '2018-04-10 00:00:00'), - ('2018-123', '2018-05-03 00:00:00'), # ordinal dates - ('2018-04-10 12:30:43', '2018-04-10 12:30:43'), - ('2018-04-10T12:30:43', '2018-04-10 12:30:43'), - ('2018-04-10 12:30:43Z', '2018-04-10 12:30:43'), - ('2018-04-10 12:30:43.1233', '2018-04-10 12:30:43'), - ('2018-04-10 12:30:43+03:00', '2018-04-10 12:30:43'), - ('2018-04-10 12:30:43-07:00', '2018-04-10 12:30:43'), - ('2018-04-10T12:30:43-07:00', '2018-04-10 12:30:43'), - ('2018-04-10 12:30', '2018-04-10 12:30:00'), - ('2018-04-10T12:30', '2018-04-10 12:30:00'), - ('2018-04-10 12', '2018-04-10 12:00:00'), - ('2018-04-10T12', '2018-04-10 12:00:00'), + ("2018", "2018-01-01 00:00:00"), # years + ("2018-04", "2018-04-01 00:00:00"), # calendar dates + ("2018-04-10", "2018-04-10 00:00:00"), + ("2018/04/10", "2018-04-10 00:00:00"), + ("2018.04.10", "2018-04-10 00:00:00"), + ("2018-4-10", "2018-04-10 00:00:00"), + ("2018/4/10", "2018-04-10 00:00:00"), + ("2018.4.10", "2018-04-10 00:00:00"), + ("20180410", "2018-04-10 00:00:00"), + ("2018-123", "2018-05-03 00:00:00"), # ordinal dates + ("2018-04-10 12:30:43", "2018-04-10 12:30:43"), + ("2018-04-10T12:30:43", "2018-04-10 12:30:43"), + ("2018-04-10 12:30:43Z", "2018-04-10 12:30:43"), + ("2018-04-10 12:30:43.1233", "2018-04-10 12:30:43"), + ("2018-04-10 12:30:43+03:00", "2018-04-10 12:30:43"), + ("2018-04-10 12:30:43-07:00", "2018-04-10 12:30:43"), + ("2018-04-10T12:30:43-07:00", "2018-04-10 12:30:43"), + ("2018-04-10 12:30", "2018-04-10 12:30:00"), + ("2018-04-10T12:30", "2018-04-10 12:30:00"), + ("2018-04-10 12", "2018-04-10 12:00:00"), + ("2018-04-10T12", "2018-04-10 12:00:00"), ( - '14:05:12', - arrow.now() - .replace(hour=14, minute=5, second=12) - .format('YYYY-MM-DD HH:mm:ss') + "14:05:12", + arrow.now().replace(hour=14, minute=5, second=12).format("YYYY-MM-DD HH:mm:ss"), ), ( - '14:05', - arrow.now() - .replace(hour=14, minute=5, second=0) - .format('YYYY-MM-DD HH:mm:ss') + "14:05", + arrow.now().replace(hour=14, minute=5, second=0).format("YYYY-MM-DD HH:mm:ss"), ), - ('2018-W08', '2018-02-19 00:00:00'), # week dates - ('2018W08', '2018-02-19 00:00:00'), - ('2018-W08-2', '2018-02-20 00:00:00'), - ('2018W082', '2018-02-20 00:00:00'), + ("2018-W08", "2018-02-19 00:00:00"), # week dates + ("2018W08", "2018-02-19 00:00:00"), + ("2018-W08-2", "2018-02-20 00:00:00"), + ("2018W082", "2018-02-20 00:00:00"), ] INVALID_DATES_DATA = [ - (' 2018'), - ('2018 '), - ('201804'), - ('18-04-10'), - ('180410'), # truncated representation not allowed - ('hello 2018'), - ('yesterday'), - ('tomorrow'), - ('14:05:12.000'), # Times alone are not allowed - ('140512.000'), - ('140512'), - ('14.05'), - ('2018-04-10T'), - ('2018-04-10T12:30:43.'), + (" 2018"), + ("2018 "), + ("201804"), + ("18-04-10"), + ("180410"), # truncated representation not allowed + ("hello 2018"), + ("yesterday"), + ("tomorrow"), + ("14:05:12.000"), # Times alone are not allowed + ("140512.000"), + ("140512"), + ("14.05"), + ("2018-04-10T"), + ("2018-04-10T12:30:43."), ] VALID_TIMES_DATA = [ - ('14:12'), - ('14:12:43'), - ('2019-04-10T14:12'), - ('2019-04-10T14:12:43'), + ("14:12"), + ("14:12:43"), + ("2019-04-10T14:12"), + ("2019-04-10T14:12:43"), ] class OutputParser: - FRAME_ID_PATTERN = re.compile(r'id: (?P[0-9a-f]+)') + FRAME_ID_PATTERN = re.compile(r"id: (?P[0-9a-f]+)") @staticmethod def get_frame_id(output): - return OutputParser.FRAME_ID_PATTERN.search(output).group('frame_id') + return OutputParser.FRAME_ID_PATTERN.search(output).group("frame_id") @staticmethod def get_start_date(watson, output): frame_id = OutputParser.get_frame_id(output) - return watson.frames[frame_id].start.format('YYYY-MM-DD HH:mm:ss') + return watson.frames[frame_id].start.format("YYYY-MM-DD HH:mm:ss") # watson add -@pytest.mark.parametrize('test_dt,expected', VALID_DATES_DATA) + +@pytest.mark.parametrize("test_dt,expected", VALID_DATES_DATA) def test_add_valid_date(runner, watson, test_dt, expected): result = runner.invoke( - cli.add, - ['-f', test_dt, '-t', test_dt, 'project-name'], - obj=watson) + cli.add, ["-f", test_dt, "-t", test_dt, "project-name"], obj=watson + ) assert result.exit_code == 0 assert OutputParser.get_start_date(watson, result.output) == expected -@pytest.mark.parametrize('test_dt', INVALID_DATES_DATA) +@pytest.mark.parametrize("test_dt", INVALID_DATES_DATA) def test_add_invalid_date(runner, watson, test_dt): - result = runner.invoke(cli.add, - ['-f', test_dt, '-t', test_dt, 'project-name'], - obj=watson) + result = runner.invoke( + cli.add, ["-f", test_dt, "-t", test_dt, "project-name"], obj=watson + ) assert result.exit_code != 0 # watson aggregate -@pytest.mark.parametrize('test_dt,expected', VALID_DATES_DATA) + +@pytest.mark.parametrize("test_dt,expected", VALID_DATES_DATA) def test_aggregate_valid_date(runner, watson, test_dt, expected): # This is super fast, because no internal 'report' invocations are made - result = runner.invoke(cli.aggregate, - ['-f', test_dt, '-t', test_dt], - obj=watson) + result = runner.invoke(cli.aggregate, ["-f", test_dt, "-t", test_dt], obj=watson) assert result.exit_code == 0 -@pytest.mark.parametrize('test_dt', INVALID_DATES_DATA) +@pytest.mark.parametrize("test_dt", INVALID_DATES_DATA) def test_aggregate_invalid_date(runner, watson, test_dt): # This is super fast, because no internal 'report' invocations are made - result = runner.invoke(cli.aggregate, - ['-f', test_dt, '-t', test_dt], - obj=watson) + result = runner.invoke(cli.aggregate, ["-f", test_dt, "-t", test_dt], obj=watson) assert result.exit_code != 0 # watson log -@pytest.mark.parametrize('cmd', [cli.aggregate, cli.log, cli.report]) + +@pytest.mark.parametrize("cmd", [cli.aggregate, cli.log, cli.report]) def test_incompatible_options(runner, watson, cmd): - name_interval_options = ['--' + s for s in cli._SHORTCUT_OPTIONS] + name_interval_options = ["--" + s for s in cli._SHORTCUT_OPTIONS] for opt1, opt2 in combinations(name_interval_options, 2): result = runner.invoke(cmd, [opt1, opt2], obj=watson) assert result.exit_code != 0 -@pytest.mark.parametrize('test_dt,expected', VALID_DATES_DATA) +@pytest.mark.parametrize("test_dt,expected", VALID_DATES_DATA) def test_log_valid_date(runner, watson, test_dt, expected): - result = runner.invoke(cli.log, ['-f', test_dt, '-t', test_dt], obj=watson) + result = runner.invoke(cli.log, ["-f", test_dt, "-t", test_dt], obj=watson) assert result.exit_code == 0 -@pytest.mark.parametrize('test_dt', INVALID_DATES_DATA) +@pytest.mark.parametrize("test_dt", INVALID_DATES_DATA) def test_log_invalid_date(runner, watson, test_dt): - result = runner.invoke(cli.log, ['-f', test_dt, '-t', test_dt], obj=watson) + result = runner.invoke(cli.log, ["-f", test_dt, "-t", test_dt], obj=watson) assert result.exit_code != 0 # watson report -@pytest.mark.parametrize('test_dt,expected', VALID_DATES_DATA) + +@pytest.mark.parametrize("test_dt,expected", VALID_DATES_DATA) def test_report_valid_date(runner, watson, test_dt, expected): - result = runner.invoke(cli.report, - ['-f', test_dt, '-t', test_dt], - obj=watson) + result = runner.invoke(cli.report, ["-f", test_dt, "-t", test_dt], obj=watson) assert result.exit_code == 0 -@pytest.mark.parametrize('test_dt', INVALID_DATES_DATA) +@pytest.mark.parametrize("test_dt", INVALID_DATES_DATA) def test_report_invalid_date(runner, watson, test_dt): - result = runner.invoke(cli.report, - ['-f', test_dt, '-t', test_dt], - obj=watson) + result = runner.invoke(cli.report, ["-f", test_dt, "-t", test_dt], obj=watson) assert result.exit_code != 0 # watson stop -@pytest.mark.parametrize('at_dt', VALID_TIMES_DATA) + +@pytest.mark.parametrize("at_dt", VALID_TIMES_DATA) def test_stop_valid_time(runner, watson, mocker, at_dt): - mocker.patch('arrow.arrow.dt_datetime', wraps=datetime) + mocker.patch("arrow.arrow.dt_datetime", wraps=datetime) start_dt = datetime(2019, 4, 10, 14, 0, 0, tzinfo=local_tz_info()) arrow.arrow.dt_datetime.now.return_value = start_dt - result = runner.invoke(cli.start, ['a-project'], obj=watson) + result = runner.invoke(cli.start, ["a-project"], obj=watson) assert result.exit_code == 0 # Simulate one hour has elapsed, so that 'at_dt' is older than now() # but newer than the start date. - arrow.arrow.dt_datetime.now.return_value = (start_dt + timedelta(hours=1)) - result = runner.invoke(cli.stop, ['--at', at_dt], obj=watson) + arrow.arrow.dt_datetime.now.return_value = start_dt + timedelta(hours=1) + result = runner.invoke(cli.stop, ["--at", at_dt], obj=watson) assert result.exit_code == 0 # watson start -@pytest.mark.parametrize('at_dt', VALID_TIMES_DATA) + +@pytest.mark.parametrize("at_dt", VALID_TIMES_DATA) def test_start_valid_time(runner, watson, mocker, at_dt): # Simulate a start date so that 'at_dt' is older than now(). - mocker.patch('arrow.arrow.dt_datetime', wraps=datetime) + mocker.patch("arrow.arrow.dt_datetime", wraps=datetime) start_dt = datetime(2019, 4, 10, 14, 0, 0, tzinfo=local_tz_info()) - arrow.arrow.dt_datetime.now.return_value = (start_dt + timedelta(hours=1)) - result = runner.invoke(cli.start, ['a-project', '--at', at_dt], obj=watson) + arrow.arrow.dt_datetime.now.return_value = start_dt + timedelta(hours=1) + result = runner.invoke(cli.start, ["a-project", "--at", at_dt], obj=watson) assert result.exit_code == 0 # watson restart -@pytest.mark.parametrize('at_dt', VALID_TIMES_DATA) + +@pytest.mark.parametrize("at_dt", VALID_TIMES_DATA) def test_restart_valid_time(runner, watson, mocker, at_dt): # Create a previous entry the same as in `test_stop_valid_time` - mocker.patch('arrow.arrow.dt_datetime', wraps=datetime) + mocker.patch("arrow.arrow.dt_datetime", wraps=datetime) start_dt = datetime(2019, 4, 10, 14, 0, 0, tzinfo=local_tz_info()) arrow.arrow.dt_datetime.now.return_value = start_dt - result = runner.invoke(cli.start, ['a-project'], obj=watson) + result = runner.invoke(cli.start, ["a-project"], obj=watson) # Simulate one hour has elapsed, so that 'at_dt' is older than now() # but newer than the start date. - arrow.arrow.dt_datetime.now.return_value = (start_dt + timedelta(hours=1)) - result = runner.invoke(cli.stop, ['--at', at_dt], obj=watson) + arrow.arrow.dt_datetime.now.return_value = start_dt + timedelta(hours=1) + result = runner.invoke(cli.stop, ["--at", at_dt], obj=watson) # Test that the last frame can be restarted - result = runner.invoke(cli.restart, ['--at', at_dt], obj=watson) + result = runner.invoke(cli.restart, ["--at", at_dt], obj=watson) assert result.exit_code == 0 diff --git a/tests/test_config.py b/tests/test_config.py index 8bdb04ed..eb46cb11 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -13,14 +13,14 @@ def test_config_get(mocker, watson): url = foo token = """ - mocker.patch.object(ConfigParser, 'read', mock_read(content)) + mocker.patch.object(ConfigParser, "read", mock_read(content)) config = watson.config - assert config.get('backend', 'url') == 'foo' - assert config.get('backend', 'token') == '' - assert config.get('backend', 'foo') is None - assert config.get('backend', 'foo', 'bar') == 'bar' - assert config.get('option', 'spamm') is None - assert config.get('option', 'spamm', 'eggs') == 'eggs' + assert config.get("backend", "url") == "foo" + assert config.get("backend", "token") == "" + assert config.get("backend", "foo") is None + assert config.get("backend", "foo", "bar") == "bar" + assert config.get("option", "spamm") is None + assert config.get("option", "spamm", "eggs") == "eggs" def test_config_getboolean(mocker, watson): @@ -33,18 +33,18 @@ def test_config_getboolean(mocker, watson): flag5 = false flag6 = """ - mocker.patch.object(ConfigParser, 'read', mock_read(content)) + mocker.patch.object(ConfigParser, "read", mock_read(content)) config = watson.config - assert config.getboolean('options', 'flag1') is True - assert config.getboolean('options', 'flag1', False) is True - assert config.getboolean('options', 'flag2') is True - assert config.getboolean('options', 'flag3') is True - assert config.getboolean('options', 'flag4') is True - assert config.getboolean('options', 'flag5') is False - assert config.getboolean('options', 'flag6') is False - assert config.getboolean('options', 'flag6', True) is True - assert config.getboolean('options', 'missing') is False - assert config.getboolean('options', 'missing', True) is True + assert config.getboolean("options", "flag1") is True + assert config.getboolean("options", "flag1", False) is True + assert config.getboolean("options", "flag2") is True + assert config.getboolean("options", "flag3") is True + assert config.getboolean("options", "flag4") is True + assert config.getboolean("options", "flag5") is False + assert config.getboolean("options", "flag6") is False + assert config.getboolean("options", "flag6", True) is True + assert config.getboolean("options", "missing") is False + assert config.getboolean("options", "missing", True) is True def test_config_getint(mocker, watson): @@ -54,21 +54,21 @@ def test_config_getint(mocker, watson): value2 = spamm value3 = """ - mocker.patch.object(ConfigParser, 'read', mock_read(content)) + mocker.patch.object(ConfigParser, "read", mock_read(content)) config = watson.config - assert config.getint('options', 'value1') == 42 - assert config.getint('options', 'value1', 666) == 42 - assert config.getint('options', 'missing') is None - assert config.getint('options', 'missing', 23) == 23 + assert config.getint("options", "value1") == 42 + assert config.getint("options", "value1", 666) == 42 + assert config.getint("options", "missing") is None + assert config.getint("options", "missing", 23) == 23 # default is not converted! - assert config.getint('options', 'missing', '42') == '42' - assert config.getint('options', 'missing', 6.66) == 6.66 + assert config.getint("options", "missing", "42") == "42" + assert config.getint("options", "missing", 6.66) == 6.66 with pytest.raises(ValueError): - config.getint('options', 'value2') + config.getint("options", "value2") with pytest.raises(ValueError): - config.getint('options', 'value3') + config.getint("options", "value3") def test_config_getfloat(mocker, watson): @@ -80,22 +80,22 @@ def test_config_getfloat(mocker, watson): value4 = """ - mocker.patch.object(ConfigParser, 'read', mock_read(content)) + mocker.patch.object(ConfigParser, "read", mock_read(content)) config = watson.config - assert config.getfloat('options', 'value1') == 3.14 - assert config.getfloat('options', 'value1', 6.66) == 3.14 - assert config.getfloat('options', 'value2') == 42.0 - assert isinstance(config.getfloat('options', 'value2'), float) - assert config.getfloat('options', 'missing') is None - assert config.getfloat('options', 'missing', 3.14) == 3.14 + assert config.getfloat("options", "value1") == 3.14 + assert config.getfloat("options", "value1", 6.66) == 3.14 + assert config.getfloat("options", "value2") == 42.0 + assert isinstance(config.getfloat("options", "value2"), float) + assert config.getfloat("options", "missing") is None + assert config.getfloat("options", "missing", 3.14) == 3.14 # default is not converted! - assert config.getfloat('options', 'missing', '3.14') == '3.14' + assert config.getfloat("options", "missing", "3.14") == "3.14" with pytest.raises(ValueError): - config.getfloat('options', 'value3') + config.getfloat("options", "value3") with pytest.raises(ValueError): - config.getfloat('options', 'value4') + config.getfloat("options", "value4") def test_config_getlist(mocker, watson): @@ -121,33 +121,32 @@ def test_config_getlist(mocker, watson): two #three four # five """ - mocker.patch.object(ConfigParser, 'read', mock_read(content)) + mocker.patch.object(ConfigParser, "read", mock_read(content)) gl = watson.config.getlist - assert gl('options', 'value1') == ['one', 'two three', 'four', - 'five six'] - assert gl('options', 'value2') == ['one', 'two three', 'four', - 'five six'] - assert gl('options', 'value3') == ['one', 'two three'] - assert gl('options', 'value4') == ['one', 'two three', 'four'] - assert gl('options', 'value5') == ['one', 'two #three', 'four # five'] + assert gl("options", "value1") == ["one", "two three", "four", "five six"] + assert gl("options", "value2") == ["one", "two three", "four", "five six"] + assert gl("options", "value3") == ["one", "two three"] + assert gl("options", "value4") == ["one", "two three", "four"] + assert gl("options", "value5") == ["one", "two #three", "four # five"] # default values - assert gl('options', 'novalue') == [] - assert gl('options', 'novalue', None) == [] - assert gl('options', 'novalue', 42) == 42 - assert gl('nosection', 'dummy') == [] - assert gl('nosection', 'dummy', None) == [] - assert gl('nosection', 'dummy', 42) == 42 - - default = gl('nosection', 'dummy') + assert gl("options", "novalue") == [] + assert gl("options", "novalue", None) == [] + assert gl("options", "novalue", 42) == 42 + assert gl("nosection", "dummy") == [] + assert gl("nosection", "dummy", None) == [] + assert gl("nosection", "dummy", 42) == 42 + + default = gl("nosection", "dummy") default.append(42) - assert gl('nosection', 'dummy') != [42], ( - "Modifying default return value should not have side effect.") + assert gl("nosection", "dummy") != [ + 42 + ], "Modifying default return value should not have side effect." def test_set_config(watson): config = ConfigParser() - config.set('foo', 'bar', 'lol') + config.set("foo", "bar", "lol") watson.config = config - assert watson.config.get('foo', 'bar') == 'lol' + assert watson.config.get("foo", "bar") == "lol" diff --git a/tests/test_utils.py b/tests/test_utils.py index b993767f..7a032b36 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -35,36 +35,41 @@ _dt = functools.partial(datetime.datetime, tzinfo=tzutc()) -@pytest.mark.parametrize('now, mode, start_time', [ - (_dt(2016, 6, 2), 'year', _dt(2016, 1, 1)), - (_dt(2016, 6, 2), 'month', _dt(2016, 6, 1)), - (_dt(2016, 6, 2), 'week', _dt(2016, 5, 30)), - (_dt(2016, 6, 2), 'day', _dt(2016, 6, 2)), - (_dt(2016, 6, 2), 'all', _dt(1970, 1, 1)), - (_dt(2016, 6, 2), 'luna', _dt(2016, 5, 21, 21, 16)), - - (_dt(2012, 2, 24), 'year', _dt(2012, 1, 1)), - (_dt(2012, 2, 24), 'month', _dt(2012, 2, 1)), - (_dt(2012, 2, 24), 'week', _dt(2012, 2, 20)), - (_dt(2012, 2, 24), 'day', _dt(2012, 2, 24)), - (_dt(2012, 2, 24), 'all', _dt(1970, 1, 1)), - (_dt(2012, 2, 24), 'luna', _dt(2012, 2, 7, 21, 56)), -]) +@pytest.mark.parametrize( + "now, mode, start_time", + [ + (_dt(2016, 6, 2), "year", _dt(2016, 1, 1)), + (_dt(2016, 6, 2), "month", _dt(2016, 6, 1)), + (_dt(2016, 6, 2), "week", _dt(2016, 5, 30)), + (_dt(2016, 6, 2), "day", _dt(2016, 6, 2)), + (_dt(2016, 6, 2), "all", _dt(1970, 1, 1)), + (_dt(2016, 6, 2), "luna", _dt(2016, 5, 21, 21, 16)), + (_dt(2012, 2, 24), "year", _dt(2012, 1, 1)), + (_dt(2012, 2, 24), "month", _dt(2012, 2, 1)), + (_dt(2012, 2, 24), "week", _dt(2012, 2, 20)), + (_dt(2012, 2, 24), "day", _dt(2012, 2, 24)), + (_dt(2012, 2, 24), "all", _dt(1970, 1, 1)), + (_dt(2012, 2, 24), "luna", _dt(2012, 2, 7, 21, 56)), + ], +) def test_get_start_time_for_period(now, mode, start_time): with mock_datetime(now, datetime): assert get_start_time_for_period(mode).datetime == start_time -@pytest.mark.parametrize("monday_start, week_start, new_start", [ - ("2018 12 3", "monday", "2018 12 3"), - ("2018 12 3", "tuesday", "2018 12 4"), - ("2018 12 3", "wednesday", "2018 12 5"), - ("2018 12 3", "thursday", "2018 12 6"), - ("2018 12 3", "friday", "2018 11 30"), - ("2018 12 3", "saturday", "2018 12 1"), - ("2018 12 3", "sunday", "2018 12 2"), - ("2018 12 3", "typo", "2018 12 3"), -]) +@pytest.mark.parametrize( + "monday_start, week_start, new_start", + [ + ("2018 12 3", "monday", "2018 12 3"), + ("2018 12 3", "tuesday", "2018 12 4"), + ("2018 12 3", "wednesday", "2018 12 5"), + ("2018 12 3", "thursday", "2018 12 6"), + ("2018 12 3", "friday", "2018 11 30"), + ("2018 12 3", "saturday", "2018 12 1"), + ("2018 12 3", "sunday", "2018 12 2"), + ("2018 12 3", "typo", "2018 12 3"), + ], +) def test_apply_weekday_offset(monday_start, week_start, new_start): with mock_datetime(_dt(2018, 12, 6), datetime): original_start = arrow.get(monday_start, "YYYY MM D") @@ -74,36 +79,36 @@ def test_apply_weekday_offset(monday_start, week_start, new_start): def test_make_json_writer(): fp = StringIO() - writer = make_json_writer(lambda: {'foo': 42}) + writer = make_json_writer(lambda: {"foo": 42}) writer(fp) assert fp.getvalue() == '{\n "foo": 42\n}' def test_make_json_writer_with_args(): fp = StringIO() - writer = make_json_writer(lambda x: {'foo': x}, 23) + writer = make_json_writer(lambda x: {"foo": x}, 23) writer(fp) assert fp.getvalue() == '{\n "foo": 23\n}' def test_make_json_writer_with_kwargs(): fp = StringIO() - writer = make_json_writer(lambda foo=None: {'foo': foo}, foo='bar') + writer = make_json_writer(lambda foo=None: {"foo": foo}, foo="bar") writer(fp) assert fp.getvalue() == '{\n "foo": "bar"\n}' def test_make_json_writer_with_unicode(): fp = StringIO() - writer = make_json_writer(lambda: {'ùñï©ôð€': 'εvεrywhεrε'}) + writer = make_json_writer(lambda: {"ùñï©ôð€": "εvεrywhεrε"}) writer(fp) expected = '{\n "ùñï©ôð€": "εvεrywhεrε"\n}' assert fp.getvalue() == expected def test_safe_save(config_dir): - save_file = os.path.join(config_dir, 'test') - backup_file = os.path.join(config_dir, 'test' + '.bak') + save_file = os.path.join(config_dir, "test") + backup_file = os.path.join(config_dir, "test" + ".bak") assert not os.path.exists(save_file) safe_save(save_file, lambda f: f.write("Success")) @@ -126,8 +131,8 @@ def test_safe_save(config_dir): def test_safe_save_tmpfile_on_other_filesystem(config_dir, mocker): - save_file = os.path.join(config_dir, 'test') - backup_file = os.path.join(config_dir, 'test' + '.bak') + save_file = os.path.join(config_dir, "test") + backup_file = os.path.join(config_dir, "test" + ".bak") assert not os.path.exists(save_file) safe_save(save_file, lambda f: f.write("Success")) @@ -139,7 +144,7 @@ def test_safe_save_tmpfile_on_other_filesystem(config_dir, mocker): # simulate tmpfile being on another file-system # OSError is caught and handled by shutil.move() used by save_safe() - mocker.patch('os.rename', side_effect=OSError) + mocker.patch("os.rename", side_effect=OSError) safe_save(save_file, "Again") assert os.path.exists(backup_file) @@ -148,8 +153,8 @@ def test_safe_save_tmpfile_on_other_filesystem(config_dir, mocker): def test_safe_save_with_exception(config_dir): - save_file = os.path.join(config_dir, 'test') - backup_file = os.path.join(config_dir, 'test' + '.bak') + save_file = os.path.join(config_dir, "test") + backup_file = os.path.join(config_dir, "test" + ".bak") def failing_writer(f): raise RuntimeError("Save failed.") @@ -175,164 +180,175 @@ def failing_writer(f): assert not os.path.exists(backup_file) -@pytest.mark.parametrize('args, parsed_tags', [ - (['+ham', '+n', '+eggs'], ['ham', 'n', 'eggs']), - (['+ham', 'n', '+eggs'], ['ham n', 'eggs']), - (['ham', 'n', '+eggs'], ['eggs']), - (['ham', '+n', 'eggs'], ['n eggs']), - (['+ham', 'n', 'eggs'], ['ham n eggs']), -]) +@pytest.mark.parametrize( + "args, parsed_tags", + [ + (["+ham", "+n", "+eggs"], ["ham", "n", "eggs"]), + (["+ham", "n", "+eggs"], ["ham n", "eggs"]), + (["ham", "n", "+eggs"], ["eggs"]), + (["ham", "+n", "eggs"], ["n eggs"]), + (["+ham", "n", "eggs"], ["ham n eggs"]), + ], +) def test_parse_tags(args, parsed_tags): tags = parse_tags(args) assert tags == parsed_tags def test_confirm_project_existing_project_returns_true(): - project = 'foo' - watson_projects = ['foo', 'bar'] + project = "foo" + watson_projects = ["foo", "bar"] assert confirm_project(project, watson_projects) -@patch('click.confirm', return_value=True) +@patch("click.confirm", return_value=True) def test_confirm_project_accept_returns_true(confirm): - project = 'baz' - watson_projects = ['foo', 'bar'] + project = "baz" + watson_projects = ["foo", "bar"] assert confirm_project(project, watson_projects) -@patch('watson.utils.click.confirm', side_effect=Abort) +@patch("watson.utils.click.confirm", side_effect=Abort) def test_confirm_project_reject_raises_abort(confirm): - project = 'baz' - watson_projects = ['foo', 'bar'] + project = "baz" + watson_projects = ["foo", "bar"] with pytest.raises(Abort): confirm_project(project, watson_projects) def test_confirm_tags_existing_tag_returns_true(): - tags = ['a'] - watson_tags = ['a', 'b'] + tags = ["a"] + watson_tags = ["a", "b"] assert confirm_tags(tags, watson_tags) -@patch('click.confirm', return_value=True) +@patch("click.confirm", return_value=True) def test_confirm_tags_accept_returns_true(confirm): - tags = ['c'] - watson_tags = ['a', 'b'] + tags = ["c"] + watson_tags = ["a", "b"] assert confirm_tags(tags, watson_tags) -@patch('click.confirm', side_effect=Abort) +@patch("click.confirm", side_effect=Abort) def test_confirm_tags_reject_raises_abort(confirm): - tags = ['c'] - watson_tags = ['a', 'b'] + tags = ["c"] + watson_tags = ["a", "b"] with pytest.raises(Abort): confirm_project(tags[0], watson_tags) # build_csv + def test_build_csv_empty_data(): - assert build_csv([]) == '' + assert build_csv([]) == "" def test_build_csv_one_col(): lt = os.linesep - data = [{'col': 'value'}, {'col': 'another value'}] - result = lt.join(['col', 'value', 'another value']) + lt + data = [{"col": "value"}, {"col": "another value"}] + result = lt.join(["col", "value", "another value"]) + lt assert build_csv(data) == result def test_build_csv_multiple_cols(): lt = os.linesep - dm = csv.get_dialect('excel').delimiter + dm = csv.get_dialect("excel").delimiter data = [ - co.OrderedDict([('col1', 'value'), - ('col2', 'another value'), - ('col3', 'more')]), - co.OrderedDict([('col1', 'one value'), - ('col2', 'two value'), - ('col3', 'three')]) + co.OrderedDict( + [("col1", "value"), ("col2", "another value"), ("col3", "more")] + ), + co.OrderedDict( + [("col1", "one value"), ("col2", "two value"), ("col3", "three")] + ), ] - result = lt.join([ - dm.join(['col1', 'col2', 'col3']), - dm.join(['value', 'another value', 'more']), - dm.join(['one value', 'two value', 'three']) - ]) + lt + result = ( + lt.join( + [ + dm.join(["col1", "col2", "col3"]), + dm.join(["value", "another value", "more"]), + dm.join(["one value", "two value", "three"]), + ] + ) + + lt + ) assert build_csv(data) == result # sorted_groupby + def test_sorted_groupby(watson): end = arrow.utcnow() - watson.add('foo', end.shift(hours=-25), end.shift(hours=-24), ['A']) - watson.add('bar', end.shift(hours=-1), end, ['A']) + watson.add("foo", end.shift(hours=-25), end.shift(hours=-24), ["A"]) + watson.add("bar", end.shift(hours=-1), end, ["A"]) - result = list(sorted_groupby( - watson.frames, - operator.attrgetter('day'), - reverse=False)) + result = list( + sorted_groupby(watson.frames, operator.attrgetter("day"), reverse=False) + ) assert result[0][0] < result[1][0] def test_sorted_groupby_reverse(watson): end = arrow.utcnow() - watson.add('foo', end.shift(hours=-25), end.shift(hours=-24), ['A']) - watson.add('bar', end.shift(hours=-1), end, ['A']) + watson.add("foo", end.shift(hours=-25), end.shift(hours=-24), ["A"]) + watson.add("bar", end.shift(hours=-1), end, ["A"]) - result = list(sorted_groupby( - watson.frames, - operator.attrgetter('day'), - reverse=True)) + result = list( + sorted_groupby(watson.frames, operator.attrgetter("day"), reverse=True) + ) assert result[0][0] > result[1][0] # frames_to_csv + def test_frames_to_csv_empty_data(watson): - assert frames_to_csv(watson.frames) == '' + assert frames_to_csv(watson.frames) == "" def test_frames_to_csv(watson): - watson.start('foo', tags=['A', 'B']) + watson.start("foo", tags=["A", "B"]) watson.stop() result = frames_to_csv(watson.frames) read_csv = list(csv.reader(StringIO(result))) - header = ['id', 'start', 'stop', 'project', 'tags'] + header = ["id", "start", "stop", "project", "tags"] assert len(read_csv) == 2 assert read_csv[0] == header - assert read_csv[1][3] == 'foo' - assert read_csv[1][4] == 'A, B' + assert read_csv[1][3] == "foo" + assert read_csv[1][4] == "A, B" # frames_to_json + def test_frames_to_json_empty_data(watson): - assert frames_to_json(watson.frames) == '[]' + assert frames_to_json(watson.frames) == "[]" def test_frames_to_json(watson): - watson.start('foo', tags=['A', 'B']) + watson.start("foo", tags=["A", "B"]) watson.stop() result = json.loads(frames_to_json(watson.frames)) - keys = {'id', 'start', 'stop', 'project', 'tags'} + keys = {"id", "start", "stop", "project", "tags"} assert len(result) == 1 assert set(result[0].keys()) == keys - assert result[0]['project'] == 'foo' - assert result[0]['tags'] == ['A', 'B'] + assert result[0]["project"] == "foo" + assert result[0]["tags"] == ["A", "B"] # flatten_report_for_csv + def test_flatten_report_for_csv(watson): - now = arrow.utcnow().ceil('hour') - watson.add('foo', now.shift(hours=-4), now, ['A', 'B']) - watson.add('foo', now.shift(hours=-5), now.shift(hours=-4), ['A']) - watson.add('foo', now.shift(hours=-7), now.shift(hours=-5), ['B']) + now = arrow.utcnow().ceil("hour") + watson.add("foo", now.shift(hours=-4), now, ["A", "B"]) + watson.add("foo", now.shift(hours=-5), now.shift(hours=-4), ["A"]) + watson.add("foo", now.shift(hours=-7), now.shift(hours=-5), ["B"]) start = now.shift(days=-1) stop = now @@ -340,23 +356,23 @@ def test_flatten_report_for_csv(watson): assert len(result) == 3 - assert result[0]['from'] == start.format('YYYY-MM-DD 00:00:00') - assert result[0]['to'] == stop.format('YYYY-MM-DD 23:59:59') - assert result[0]['project'] == 'foo' - assert result[0]['tag'] == '' - assert result[0]['time'] == (4 + 1 + 2) * 3600 + assert result[0]["from"] == start.format("YYYY-MM-DD 00:00:00") + assert result[0]["to"] == stop.format("YYYY-MM-DD 23:59:59") + assert result[0]["project"] == "foo" + assert result[0]["tag"] == "" + assert result[0]["time"] == (4 + 1 + 2) * 3600 - assert result[1]['from'] == start.format('YYYY-MM-DD 00:00:00') - assert result[1]['to'] == stop.format('YYYY-MM-DD 23:59:59') - assert result[1]['project'] == 'foo' - assert result[1]['tag'] == 'A' - assert result[1]['time'] == (4 + 1) * 3600 + assert result[1]["from"] == start.format("YYYY-MM-DD 00:00:00") + assert result[1]["to"] == stop.format("YYYY-MM-DD 23:59:59") + assert result[1]["project"] == "foo" + assert result[1]["tag"] == "A" + assert result[1]["time"] == (4 + 1) * 3600 - assert result[2]['from'] == start.format('YYYY-MM-DD 00:00:00') - assert result[2]['to'] == stop.format('YYYY-MM-DD 23:59:59') - assert result[2]['project'] == 'foo' - assert result[2]['tag'] == 'B' - assert result[2]['time'] == (4 + 2) * 3600 + assert result[2]["from"] == start.format("YYYY-MM-DD 00:00:00") + assert result[2]["to"] == stop.format("YYYY-MM-DD 23:59:59") + assert result[2]["project"] == "foo" + assert result[2]["tag"] == "B" + assert result[2]["time"] == (4 + 2) * 3600 def test_json_arrow_encoder(): @@ -364,7 +380,7 @@ def test_json_arrow_encoder(): json_arrow_encoder(0) with pytest.raises(TypeError): - json_arrow_encoder('foo') + json_arrow_encoder("foo") with pytest.raises(TypeError): json_arrow_encoder(None) diff --git a/tests/test_watson.py b/tests/test_watson.py index b44ee172..958ac2fd 100644 --- a/tests/test_watson.py +++ b/tests/test_watson.py @@ -17,9 +17,7 @@ @pytest.fixture def json_mock(mocker): - return mocker.patch.object( - json, 'dumps', side_effect=json.dumps, autospec=True - ) + return mocker.patch.object(json, "dumps", side_effect=json.dumps, autospec=True) # NOTE: All timestamps need to be > 3600 to avoid breaking the tests on @@ -27,49 +25,49 @@ def json_mock(mocker): # current + def test_current(mocker, watson): - content = json.dumps({'project': 'foo', 'start': 4000, 'tags': ['A', 'B']}) + content = json.dumps({"project": "foo", "start": 4000, "tags": ["A", "B"]}) - mocker.patch('builtins.open', mocker.mock_open(read_data=content)) - assert watson.current['project'] == 'foo' - assert watson.current['start'] == arrow.get(4000) - assert watson.current['tags'] == ['A', 'B'] + mocker.patch("builtins.open", mocker.mock_open(read_data=content)) + assert watson.current["project"] == "foo" + assert watson.current["start"] == arrow.get(4000) + assert watson.current["tags"] == ["A", "B"] def test_current_with_empty_file(mocker, watson): - mocker.patch('builtins.open', mocker.mock_open(read_data="")) - mocker.patch('os.path.getsize', return_value=0) + mocker.patch("builtins.open", mocker.mock_open(read_data="")) + mocker.patch("os.path.getsize", return_value=0) assert watson.current == {} def test_current_with_nonexistent_file(mocker, watson): - mocker.patch('builtins.open', side_effect=IOError) + mocker.patch("builtins.open", side_effect=IOError) assert watson.current == {} def test_current_watson_non_valid_json(mocker, watson): content = "{'foo': bar}" - mocker.patch('builtins.open', mocker.mock_open(read_data=content)) - mocker.patch('os.path.getsize', return_value=len(content)) + mocker.patch("builtins.open", mocker.mock_open(read_data=content)) + mocker.patch("os.path.getsize", return_value=len(content)) with pytest.raises(WatsonError): watson.current def test_current_with_given_state(config_dir, mocker): - content = json.dumps({'project': 'foo', 'start': 4000}) - watson = Watson(current={'project': 'bar', 'start': 4000}, - config_dir=config_dir) + content = json.dumps({"project": "foo", "start": 4000}) + watson = Watson(current={"project": "bar", "start": 4000}, config_dir=config_dir) - mocker.patch('builtins.open', mocker.mock_open(read_data=content)) - assert watson.current['project'] == 'bar' + mocker.patch("builtins.open", mocker.mock_open(read_data=content)) + assert watson.current["project"] == "bar" def test_current_with_empty_given_state(config_dir, mocker): - content = json.dumps({'project': 'foo', 'start': 4000}) + content = json.dumps({"project": "foo", "start": 4000}) watson = Watson(current=[], config_dir=config_dir) - mocker.patch('builtins.open', mocker.mock_open(read_data=content)) + mocker.patch("builtins.open", mocker.mock_open(read_data=content)) assert watson.current == {} @@ -78,42 +76,43 @@ def test_current_as_running_frame(watson): Ensures frame can be created without a stop date. Catches #417: editing task in progress throws an exception """ - watson.start('foo', tags=['A', 'B']) + watson.start("foo", tags=["A", "B"]) cur = watson.current - frame = Frame(cur['start'], None, cur['project'], None, cur['tags']) + frame = Frame(cur["start"], None, cur["project"], None, cur["tags"]) assert frame.stop is None - assert frame.project == 'foo' - assert frame.tags == ['A', 'B'] + assert frame.project == "foo" + assert frame.tags == ["A", "B"] # last_sync + def test_last_sync(mocker, watson): now = arrow.get(4123) content = json.dumps(now.int_timestamp) - mocker.patch('builtins.open', mocker.mock_open(read_data=content)) + mocker.patch("builtins.open", mocker.mock_open(read_data=content)) assert watson.last_sync == now def test_last_sync_with_empty_file(mocker, watson): - mocker.patch('builtins.open', mocker.mock_open(read_data="")) - mocker.patch('os.path.getsize', return_value=0) + mocker.patch("builtins.open", mocker.mock_open(read_data="")) + mocker.patch("os.path.getsize", return_value=0) assert watson.last_sync == arrow.get(0) def test_last_sync_with_nonexistent_file(mocker, watson): - mocker.patch('builtins.open', side_effect=IOError) + mocker.patch("builtins.open", side_effect=IOError) assert watson.last_sync == arrow.get(0) def test_last_sync_watson_non_valid_json(mocker, watson): content = "{'foo': bar}" - mocker.patch('builtins.open', mocker.mock_open(read_data=content)) - mocker.patch('os.path.getsize', return_value=len(content)) + mocker.patch("builtins.open", mocker.mock_open(read_data=content)) + mocker.patch("os.path.getsize", return_value=len(content)) with pytest.raises(WatsonError): watson.last_sync @@ -123,7 +122,7 @@ def test_last_sync_with_given_state(config_dir, mocker): now = arrow.now() watson = Watson(last_sync=now, config_dir=config_dir) - mocker.patch('builtins.open', mocker.mock_open(read_data=content)) + mocker.patch("builtins.open", mocker.mock_open(read_data=content)) assert watson.last_sync == now @@ -131,124 +130,128 @@ def test_last_sync_with_empty_given_state(config_dir, mocker): content = json.dumps(123) watson = Watson(last_sync=None, config_dir=config_dir) - mocker.patch('builtins.open', mocker.mock_open(read_data=content)) + mocker.patch("builtins.open", mocker.mock_open(read_data=content)) assert watson.last_sync == arrow.get(0) # frames + def test_frames(mocker, watson): - content = json.dumps([[4000, 4010, 'foo', None, ['A', 'B', 'C']]]) + content = json.dumps([[4000, 4010, "foo", None, ["A", "B", "C"]]]) - mocker.patch('builtins.open', mocker.mock_open(read_data=content)) + mocker.patch("builtins.open", mocker.mock_open(read_data=content)) assert len(watson.frames) == 1 - assert watson.frames[0].project == 'foo' + assert watson.frames[0].project == "foo" assert watson.frames[0].start == arrow.get(4000) assert watson.frames[0].stop == arrow.get(4010) - assert watson.frames[0].tags == ['A', 'B', 'C'] + assert watson.frames[0].tags == ["A", "B", "C"] def test_frames_without_tags(mocker, watson): - content = json.dumps([[4000, 4010, 'foo', None]]) + content = json.dumps([[4000, 4010, "foo", None]]) - mocker.patch('builtins.open', mocker.mock_open(read_data=content)) + mocker.patch("builtins.open", mocker.mock_open(read_data=content)) assert len(watson.frames) == 1 - assert watson.frames[0].project == 'foo' + assert watson.frames[0].project == "foo" assert watson.frames[0].start == arrow.get(4000) assert watson.frames[0].stop == arrow.get(4010) assert watson.frames[0].tags == [] def test_frames_with_empty_file(mocker, watson): - mocker.patch('builtins.open', mocker.mock_open(read_data="")) - mocker.patch('os.path.getsize', return_value=0) + mocker.patch("builtins.open", mocker.mock_open(read_data="")) + mocker.patch("os.path.getsize", return_value=0) assert len(watson.frames) == 0 def test_frames_with_nonexistent_file(mocker, watson): - mocker.patch('builtins.open', side_effect=IOError) + mocker.patch("builtins.open", side_effect=IOError) assert len(watson.frames) == 0 def test_frames_watson_non_valid_json(mocker, watson): content = "{'foo': bar}" - mocker.patch('builtins.open', mocker.mock_open(read_data=content)) - mocker.patch('os.path.getsize', return_value=len(content)) + mocker.patch("builtins.open", mocker.mock_open(read_data=content)) + mocker.patch("os.path.getsize", return_value=len(content)) with pytest.raises(WatsonError): watson.frames def test_given_frames(config_dir, mocker): - content = json.dumps([[4000, 4010, 'foo', None, ['A']]]) - watson = Watson(frames=[[4000, 4010, 'bar', None, ['A', 'B']]], - config_dir=config_dir) + content = json.dumps([[4000, 4010, "foo", None, ["A"]]]) + watson = Watson( + frames=[[4000, 4010, "bar", None, ["A", "B"]]], config_dir=config_dir + ) - mocker.patch('builtins.open', mocker.mock_open(read_data=content)) + mocker.patch("builtins.open", mocker.mock_open(read_data=content)) assert len(watson.frames) == 1 - assert watson.frames[0].project == 'bar' - assert watson.frames[0].tags == ['A', 'B'] + assert watson.frames[0].project == "bar" + assert watson.frames[0].tags == ["A", "B"] def test_frames_with_empty_given_state(config_dir, mocker): - content = json.dumps([[0, 10, 'foo', None, ['A']]]) + content = json.dumps([[0, 10, "foo", None, ["A"]]]) watson = Watson(frames=[], config_dir=config_dir) - mocker.patch('builtins.open', mocker.mock_open(read_data=content)) + mocker.patch("builtins.open", mocker.mock_open(read_data=content)) assert len(watson.frames) == 0 # config + def test_empty_config_dir(): watson = Watson() - assert watson._dir == get_app_dir('watson') + assert watson._dir == get_app_dir("watson") def test_wrong_config(mocker, watson): content = """ toto """ - mocker.patch.object(ConfigParser, 'read', mock_read(content)) + mocker.patch.object(ConfigParser, "read", mock_read(content)) with pytest.raises(ConfigurationError): watson.config def test_empty_config(mocker, watson): - mocker.patch.object(ConfigParser, 'read', mock_read('')) + mocker.patch.object(ConfigParser, "read", mock_read("")) assert len(watson.config.sections()) == 0 # start + def test_start_new_project(watson): - watson.start('foo', ['A', 'B']) + watson.start("foo", ["A", "B"]) assert watson.current != {} assert watson.is_started is True - assert watson.current.get('project') == 'foo' - assert isinstance(watson.current.get('start'), arrow.Arrow) - assert watson.current.get('tags') == ['A', 'B'] + assert watson.current.get("project") == "foo" + assert isinstance(watson.current.get("start"), arrow.Arrow) + assert watson.current.get("tags") == ["A", "B"] def test_start_new_project_without_tags(watson): - watson.start('foo') + watson.start("foo") assert watson.current != {} assert watson.is_started is True - assert watson.current.get('project') == 'foo' - assert isinstance(watson.current.get('start'), arrow.Arrow) - assert watson.current.get('tags') == [] + assert watson.current.get("project") == "foo" + assert isinstance(watson.current.get("start"), arrow.Arrow) + assert watson.current.get("tags") == [] def test_start_two_projects(watson): - watson.start('foo') + watson.start("foo") with pytest.raises(WatsonError): - watson.start('bar') + watson.start("bar") assert watson.current != {} - assert watson.current['project'] == 'foo' + assert watson.current["project"] == "foo" assert watson.is_started is True @@ -258,9 +261,9 @@ def test_start_default_tags(mocker, watson): my project = A B """ - mocker.patch.object(ConfigParser, 'read', mock_read(content)) - watson.start('my project') - assert watson.current['tags'] == ['A', 'B'] + mocker.patch.object(ConfigParser, "read", mock_read(content)) + watson.start("my project") + assert watson.current["tags"] == ["A", "B"] def test_start_default_tags_with_supplementary_input_tags(mocker, watson): @@ -269,63 +272,64 @@ def test_start_default_tags_with_supplementary_input_tags(mocker, watson): my project = A B """ - mocker.patch.object(ConfigParser, 'read', mock_read(content)) - watson.start('my project', tags=['C', 'D']) - assert watson.current['tags'] == ['C', 'D', 'A', 'B'] + mocker.patch.object(ConfigParser, "read", mock_read(content)) + watson.start("my project", tags=["C", "D"]) + assert watson.current["tags"] == ["C", "D", "A", "B"] def test_start_nogap(watson): - watson.start('foo') + watson.start("foo") watson.stop() - watson.start('bar', gap=False) + watson.start("bar", gap=False) - assert watson.frames[-1].stop == watson.current['start'] + assert watson.frames[-1].stop == watson.current["start"] def test_start_project_at(watson): now = arrow.now() - watson.start('foo', start_at=now) + watson.start("foo", start_at=now) watson.stop() # Task can't start before the previous task ends with pytest.raises(WatsonError): - time_str = '1970-01-01T00:00' + time_str = "1970-01-01T00:00" time_obj = arrow.get(time_str) - watson.start('foo', start_at=time_obj) + watson.start("foo", start_at=time_obj) # Task can't start in the future with pytest.raises(WatsonError): - time_str = '2999-12-31T23:59' + time_str = "2999-12-31T23:59" time_obj = arrow.get(time_str) - watson.start('foo', start_at=time_obj) + watson.start("foo", start_at=time_obj) assert watson.frames[-1].start == now # stop + def test_stop_started_project(watson): - watson.start('foo', tags=['A', 'B']) + watson.start("foo", tags=["A", "B"]) watson.stop() assert watson.current == {} assert watson.is_started is False assert len(watson.frames) == 1 - assert watson.frames[0].project == 'foo' + assert watson.frames[0].project == "foo" assert isinstance(watson.frames[0].start, arrow.Arrow) assert isinstance(watson.frames[0].stop, arrow.Arrow) - assert watson.frames[0].tags == ['A', 'B'] + assert watson.frames[0].tags == ["A", "B"] def test_stop_started_project_without_tags(watson): - watson.start('foo') + watson.start("foo") watson.stop() assert watson.current == {} assert watson.is_started is False assert len(watson.frames) == 1 - assert watson.frames[0].project == 'foo' + assert watson.frames[0].project == "foo" assert isinstance(watson.frames[0].start, arrow.Arrow) assert isinstance(watson.frames[0].stop, arrow.Arrow) assert watson.frames[0].tags == [] @@ -337,18 +341,18 @@ def test_stop_no_project(watson): def test_stop_started_project_at(watson): - watson.start('foo') + watson.start("foo") now = arrow.now() # Task can't end before it starts with pytest.raises(WatsonError): - time_str = '1970-01-01T00:00' + time_str = "1970-01-01T00:00" time_obj = arrow.get(time_str) watson.stop(stop_at=time_obj) # Task can't end in the future with pytest.raises(WatsonError): - time_str = '2999-12-31T23:59' + time_str = "2999-12-31T23:59" time_obj = arrow.get(time_str) watson.stop(stop_at=time_obj) @@ -358,8 +362,9 @@ def test_stop_started_project_at(watson): # cancel + def test_cancel_started_project(watson): - watson.start('foo') + watson.start("foo") watson.cancel() assert watson.current == {} @@ -373,53 +378,54 @@ def test_cancel_no_project(watson): # save + def test_save_without_changes(mocker, watson, json_mock): - mocker.patch('builtins.open', mocker.mock_open()) + mocker.patch("builtins.open", mocker.mock_open()) watson.save() assert not json_mock.called def test_save_current(mocker, watson, json_mock): - watson.start('foo', ['A', 'B']) + watson.start("foo", ["A", "B"]) - mocker.patch('builtins.open', mocker.mock_open()) + mocker.patch("builtins.open", mocker.mock_open()) watson.save() assert json_mock.call_count == 1 result = json_mock.call_args[0][0] - assert result['project'] == 'foo' - assert isinstance(result['start'], (int, float)) - assert result['tags'] == ['A', 'B'] + assert result["project"] == "foo" + assert isinstance(result["start"], (int, float)) + assert result["tags"] == ["A", "B"] def test_save_current_without_tags(mocker, watson, json_mock): - watson.start('foo') + watson.start("foo") - mocker.patch('builtins.open', mocker.mock_open()) + mocker.patch("builtins.open", mocker.mock_open()) watson.save() assert json_mock.call_count == 1 result = json_mock.call_args[0][0] - assert result['project'] == 'foo' - assert isinstance(result['start'], (int, float)) - assert result['tags'] == [] + assert result["project"] == "foo" + assert isinstance(result["start"], (int, float)) + assert result["tags"] == [] dump_args = json_mock.call_args[1] - assert dump_args['ensure_ascii'] is False + assert dump_args["ensure_ascii"] is False def test_save_empty_current(config_dir, mocker, json_mock): watson = Watson(current={}, config_dir=config_dir) - mocker.patch('builtins.open', mocker.mock_open()) + mocker.patch("builtins.open", mocker.mock_open()) - watson.current = {'project': 'foo', 'start': 4000} + watson.current = {"project": "foo", "start": 4000} watson.save() assert json_mock.call_count == 1 result = json_mock.call_args[0][0] - assert result == {'project': 'foo', 'start': 4000, 'tags': []} + assert result == {"project": "foo", "start": 4000, "tags": []} watson.current = {} watson.save() @@ -430,60 +436,58 @@ def test_save_empty_current(config_dir, mocker, json_mock): def test_save_frames_no_change(config_dir, mocker, json_mock): - watson = Watson(frames=[[4000, 4010, 'foo', None]], - config_dir=config_dir) + watson = Watson(frames=[[4000, 4010, "foo", None]], config_dir=config_dir) - mocker.patch('builtins.open', mocker.mock_open()) + mocker.patch("builtins.open", mocker.mock_open()) watson.save() assert not json_mock.called def test_save_added_frame(config_dir, mocker, json_mock): - watson = Watson(frames=[[4000, 4010, 'foo', None]], config_dir=config_dir) - watson.frames.add('bar', 4010, 4020, ['A']) + watson = Watson(frames=[[4000, 4010, "foo", None]], config_dir=config_dir) + watson.frames.add("bar", 4010, 4020, ["A"]) - mocker.patch('builtins.open', mocker.mock_open()) + mocker.patch("builtins.open", mocker.mock_open()) watson.save() assert json_mock.call_count == 1 result = json_mock.call_args[0][0] assert len(result) == 2 - assert result[0][2] == 'foo' + assert result[0][2] == "foo" assert result[0][4] == [] - assert result[1][2] == 'bar' - assert result[1][4] == ['A'] + assert result[1][2] == "bar" + assert result[1][4] == ["A"] def test_save_changed_frame(config_dir, mocker, json_mock): - watson = Watson(frames=[[4000, 4010, 'foo', None, ['A']]], - config_dir=config_dir) - watson.frames[0] = ('bar', 4000, 4010, ['A', 'B']) + watson = Watson(frames=[[4000, 4010, "foo", None, ["A"]]], config_dir=config_dir) + watson.frames[0] = ("bar", 4000, 4010, ["A", "B"]) - mocker.patch('builtins.open', mocker.mock_open()) + mocker.patch("builtins.open", mocker.mock_open()) watson.save() assert json_mock.call_count == 1 result = json_mock.call_args[0][0] assert len(result) == 1 - assert result[0][2] == 'bar' - assert result[0][4] == ['A', 'B'] + assert result[0][2] == "bar" + assert result[0][4] == ["A", "B"] dump_args = json_mock.call_args[1] - assert dump_args['ensure_ascii'] is False + assert dump_args["ensure_ascii"] is False def test_save_config_no_changes(mocker, watson): - mocker.patch('builtins.open', mocker.mock_open()) - write_mock = mocker.patch.object(ConfigParser, 'write') + mocker.patch("builtins.open", mocker.mock_open()) + write_mock = mocker.patch.object(ConfigParser, "write") watson.save() assert not write_mock.called def test_save_config(mocker, watson): - mocker.patch('builtins.open', mocker.mock_open()) - write_mock = mocker.patch.object(ConfigParser, 'write') + mocker.patch("builtins.open", mocker.mock_open()) + write_mock = mocker.patch.object(ConfigParser, "write") watson.config = ConfigParser() watson.save() @@ -494,7 +498,7 @@ def test_save_last_sync(mocker, watson, json_mock): now = arrow.now() watson.last_sync = now - mocker.patch('builtins.open', mocker.mock_open()) + mocker.patch("builtins.open", mocker.mock_open()) watson.save() assert json_mock.call_count == 1 @@ -505,7 +509,7 @@ def test_save_empty_last_sync(config_dir, mocker, json_mock): watson = Watson(last_sync=arrow.now(), config_dir=config_dir) watson.last_sync = None - mocker.patch('builtins.open', mocker.mock_open()) + mocker.patch("builtins.open", mocker.mock_open()) watson.save() assert json_mock.call_count == 1 @@ -513,11 +517,11 @@ def test_save_empty_last_sync(config_dir, mocker, json_mock): def test_watson_save_calls_safe_save(mocker, config_dir, watson): - frames_file = os.path.join(config_dir, 'frames') - watson.start('foo', tags=['A', 'B']) + frames_file = os.path.join(config_dir, "frames") + watson.start("foo", tags=["A", "B"]) watson.stop() - save_mock = mocker.patch('watson.watson.safe_save') + save_mock = mocker.patch("watson.watson.safe_save") watson.save() assert watson._frames.changed @@ -528,6 +532,7 @@ def test_watson_save_calls_safe_save(mocker, config_dir, watson): # push + def test_push_with_no_config(watson): config = ConfigParser() watson.config = config @@ -538,8 +543,8 @@ def test_push_with_no_config(watson): def test_push_with_no_url(watson): config = ConfigParser() - config.add_section('backend') - config.set('backend', 'token', 'bar') + config.add_section("backend") + config.set("backend", "token", "bar") watson.config = config with pytest.raises(WatsonError): @@ -548,8 +553,8 @@ def test_push_with_no_url(watson): def test_push_with_no_token(watson): config = ConfigParser() - config.add_section('backend') - config.set('backend', 'url', 'http://foo.com') + config.add_section("backend") + config.set("backend", "url", "http://foo.com") watson.config = config with pytest.raises(WatsonError): @@ -558,36 +563,40 @@ def test_push_with_no_token(watson): def test_push(mocker, watson): config = ConfigParser() - config.add_section('backend') - config.set('backend', 'url', 'http://foo.com') - config.set('backend', 'token', 'bar') + config.add_section("backend") + config.set("backend", "url", "http://foo.com") + config.set("backend", "token", "bar") - watson.frames.add('foo', 4001, 4002) - watson.frames.add('foo', 4003, 4004) + watson.frames.add("foo", 4001, 4002) + watson.frames.add("foo", 4003, 4004) watson.last_sync = arrow.now() - watson.frames.add('bar', 4001, 4002, ['A', 'B']) - watson.frames.add('lol', 4001, 4002) + watson.frames.add("bar", 4001, 4002, ["A", "B"]) + watson.frames.add("lol", 4001, 4002) last_pull = arrow.now() - watson.frames.add('foo', 4001, 4002) - watson.frames.add('bar', 4003, 4004) + watson.frames.add("foo", 4001, 4002) + watson.frames.add("bar", 4003, 4004) - mocker.patch.object(watson, '_get_remote_projects', return_value=[ - {'name': 'foo', 'id': '08288b71-4500-40dd-96b1-a995937a15fd'}, - {'name': 'bar', 'id': 'f0534272-65fa-4832-a49e-0eedf68b3a84'}, - {'name': 'lol', 'id': '7fdaf65e-66bd-4c01-b09e-74bdc8cbe552'}, - ]) + mocker.patch.object( + watson, + "_get_remote_projects", + return_value=[ + {"name": "foo", "id": "08288b71-4500-40dd-96b1-a995937a15fd"}, + {"name": "bar", "id": "f0534272-65fa-4832-a49e-0eedf68b3a84"}, + {"name": "lol", "id": "7fdaf65e-66bd-4c01-b09e-74bdc8cbe552"}, + ], + ) class Response: def __init__(self): self.status_code = 201 - mock_put = mocker.patch('requests.post', return_value=Response()) + mock_put = mocker.patch("requests.post", return_value=Response()) mocker.patch.object( - Watson, 'config', new_callable=mocker.PropertyMock, return_value=config + Watson, "config", new_callable=mocker.PropertyMock, return_value=config ) watson.push(last_pull) @@ -595,23 +604,24 @@ def __init__(self): mocker.ANY, mocker.ANY, headers={ - 'content-type': 'application/json', - 'Authorization': "Token " + config.get('backend', 'token') - } + "content-type": "application/json", + "Authorization": "Token " + config.get("backend", "token"), + }, ) frames_sent = json.loads(mock_put.call_args[0][1]) assert len(frames_sent) == 2 - assert frames_sent[0].get('project') == 'bar' - assert frames_sent[0].get('tags') == ['A', 'B'] + assert frames_sent[0].get("project") == "bar" + assert frames_sent[0].get("tags") == ["A", "B"] - assert frames_sent[1].get('project') == 'lol' - assert frames_sent[1].get('tags') == [] + assert frames_sent[1].get("project") == "lol" + assert frames_sent[1].get("tags") == [] # pull + def test_pull_with_no_config(watson): config = ConfigParser() watson.config = config @@ -622,8 +632,8 @@ def test_pull_with_no_config(watson): def test_pull_with_no_url(watson): config = ConfigParser() - config.add_section('backend') - config.set('backend', 'token', 'bar') + config.add_section("backend") + config.set("backend", "token", "bar") watson.config = config with pytest.raises(ConfigurationError): @@ -632,8 +642,8 @@ def test_pull_with_no_url(watson): def test_pull_with_no_token(watson): config = ConfigParser() - config.add_section('backend') - config.set('backend', 'url', 'http://foo.com') + config.add_section("backend") + config.set("backend", "url", "http://foo.com") watson.config = config with pytest.raises(ConfigurationError): @@ -642,20 +652,24 @@ def test_pull_with_no_token(watson): def test_pull(mocker, watson): config = ConfigParser() - config.add_section('backend') - config.set('backend', 'url', 'http://foo.com') - config.set('backend', 'token', 'bar') + config.add_section("backend") + config.set("backend", "url", "http://foo.com") + config.set("backend", "token", "bar") watson.last_sync = arrow.now() watson.frames.add( - 'foo', 4001, 4002, ['A', 'B'], id='1c006c6e6cc14c80ab22b51c857c0b06' + "foo", 4001, 4002, ["A", "B"], id="1c006c6e6cc14c80ab22b51c857c0b06" ) - mocker.patch.object(watson, '_get_remote_projects', return_value=[ - {'name': 'foo', 'id': '08288b71-4500-40dd-96b1-a995937a15fd'}, - {'name': 'bar', 'id': 'f0534272-65fa-4832-a49e-0eedf68b3a84'}, - ]) + mocker.patch.object( + watson, + "_get_remote_projects", + return_value=[ + {"name": "foo", "id": "08288b71-4500-40dd-96b1-a995937a15fd"}, + {"name": "bar", "id": "f0534272-65fa-4832-a49e-0eedf68b3a84"}, + ], + ) class Response: def __init__(self): @@ -664,46 +678,46 @@ def __init__(self): def json(self): return [ { - 'id': '1c006c6e-6cc1-4c80-ab22-b51c857c0b06', - 'project': 'foo', - 'begin_at': 4003, - 'end_at': 4004, - 'tags': ['A'] + "id": "1c006c6e-6cc1-4c80-ab22-b51c857c0b06", + "project": "foo", + "begin_at": 4003, + "end_at": 4004, + "tags": ["A"], }, { - 'id': 'c44aa815-4d77-4a58-bddd-1afa95562141', - 'project': 'bar', - 'begin_at': 4004, - 'end_at': 4005, - 'tags': [] - } + "id": "c44aa815-4d77-4a58-bddd-1afa95562141", + "project": "bar", + "begin_at": 4004, + "end_at": 4005, + "tags": [], + }, ] - mocker.patch('requests.get', return_value=Response()) + mocker.patch("requests.get", return_value=Response()) mocker.patch.object( - Watson, 'config', new_callable=mocker.PropertyMock, return_value=config + Watson, "config", new_callable=mocker.PropertyMock, return_value=config ) watson.pull() requests.get.assert_called_once_with( mocker.ANY, - params={'last_sync': watson.last_sync}, + params={"last_sync": watson.last_sync}, headers={ - 'content-type': 'application/json', - 'Authorization': "Token " + config.get('backend', 'token') - } + "content-type": "application/json", + "Authorization": "Token " + config.get("backend", "token"), + }, ) assert len(watson.frames) == 2 - assert watson.frames[0].id == '1c006c6e6cc14c80ab22b51c857c0b06' - assert watson.frames[0].project == 'foo' + assert watson.frames[0].id == "1c006c6e6cc14c80ab22b51c857c0b06" + assert watson.frames[0].project == "foo" assert watson.frames[0].start.int_timestamp == 4003 assert watson.frames[0].stop.int_timestamp == 4004 - assert watson.frames[0].tags == ['A'] + assert watson.frames[0].tags == ["A"] - assert watson.frames[1].id == 'c44aa8154d774a58bddd1afa95562141' - assert watson.frames[1].project == 'bar' + assert watson.frames[1].id == "c44aa8154d774a58bddd1afa95562141" + assert watson.frames[1].project == "bar" assert watson.frames[1].start.int_timestamp == 4004 assert watson.frames[1].stop.int_timestamp == 4005 assert watson.frames[1].tags == [] @@ -711,11 +725,12 @@ def json(self): # projects + def test_projects(watson): - for name in ('foo', 'bar', 'bar', 'bar', 'foo', 'lol'): + for name in ("foo", "bar", "bar", "bar", "foo", "lol"): watson.frames.add(name, 4000, 4000) - assert watson.projects == ['bar', 'foo', 'lol'] + assert watson.projects == ["bar", "foo", "lol"] def test_projects_no_frames(watson): @@ -724,19 +739,20 @@ def test_projects_no_frames(watson): # tags + def test_tags(watson): samples = ( - ('foo', ('A', 'D')), - ('bar', ('A', 'C')), - ('foo', ('B', 'C')), - ('lol', ()), - ('bar', ('C')) + ("foo", ("A", "D")), + ("bar", ("A", "C")), + ("foo", ("B", "C")), + ("lol", ()), + ("bar", ("C")), ) for name, tags in samples: watson.frames.add(name, 4000, 4000, tags) - assert watson.tags == ['A', 'B', 'C', 'D'] + assert watson.tags == ["A", "B", "C", "D"] def test_tags_no_frames(watson): @@ -745,73 +761,71 @@ def test_tags_no_frames(watson): # merge + @pytest.mark.datafiles( - TEST_FIXTURE_DIR / 'frames-with-conflict', - ) + TEST_FIXTURE_DIR / "frames-with-conflict", +) def test_merge_report(watson, datafiles): # Get report - watson.frames.add('foo', 4000, 4015, id='1', updated_at=4015) - watson.frames.add('bar', 4020, 4045, id='2', updated_at=4045) + watson.frames.add("foo", 4000, 4015, id="1", updated_at=4015) + watson.frames.add("bar", 4020, 4045, id="2", updated_at=4045) - conflicting, merging = watson.merge_report( - str(datafiles) + '/frames-with-conflict') + conflicting, merging = watson.merge_report(str(datafiles) + "/frames-with-conflict") assert len(conflicting) == 1 assert len(merging) == 1 - assert conflicting[0].id == '2' - assert merging[0].id == '3' + assert conflicting[0].id == "2" + assert merging[0].id == "3" def test_report(watson): - watson.start('foo', tags=['A', 'B']) + watson.start("foo", tags=["A", "B"]) watson.stop() report = watson.report(arrow.now(), arrow.now()) - assert 'time' in report - assert 'timespan' in report - assert 'from' in report['timespan'] - assert 'to' in report['timespan'] - assert len(report['projects']) == 1 - assert report['projects'][0]['name'] == 'foo' - assert len(report['projects'][0]['tags']) == 2 - assert report['projects'][0]['tags'][0]['name'] == 'A' - assert 'time' in report['projects'][0]['tags'][0] - assert report['projects'][0]['tags'][1]['name'] == 'B' - assert 'time' in report['projects'][0]['tags'][1] - - watson.start('bar', tags=['C']) + assert "time" in report + assert "timespan" in report + assert "from" in report["timespan"] + assert "to" in report["timespan"] + assert len(report["projects"]) == 1 + assert report["projects"][0]["name"] == "foo" + assert len(report["projects"][0]["tags"]) == 2 + assert report["projects"][0]["tags"][0]["name"] == "A" + assert "time" in report["projects"][0]["tags"][0] + assert report["projects"][0]["tags"][1]["name"] == "B" + assert "time" in report["projects"][0]["tags"][1] + + watson.start("bar", tags=["C"]) watson.stop() report = watson.report(arrow.now(), arrow.now()) - assert len(report['projects']) == 2 - assert report['projects'][0]['name'] == 'bar' - assert report['projects'][1]['name'] == 'foo' - assert len(report['projects'][0]['tags']) == 1 - assert report['projects'][0]['tags'][0]['name'] == 'C' - - report = watson.report( - arrow.now(), arrow.now(), projects=['foo'], tags=['B'] - ) - assert len(report['projects']) == 1 - assert report['projects'][0]['name'] == 'foo' - assert len(report['projects'][0]['tags']) == 1 - assert report['projects'][0]['tags'][0]['name'] == 'B' - - watson.start('baz', tags=['D']) + assert len(report["projects"]) == 2 + assert report["projects"][0]["name"] == "bar" + assert report["projects"][1]["name"] == "foo" + assert len(report["projects"][0]["tags"]) == 1 + assert report["projects"][0]["tags"][0]["name"] == "C" + + report = watson.report(arrow.now(), arrow.now(), projects=["foo"], tags=["B"]) + assert len(report["projects"]) == 1 + assert report["projects"][0]["name"] == "foo" + assert len(report["projects"][0]["tags"]) == 1 + assert report["projects"][0]["tags"][0]["name"] == "B" + + watson.start("baz", tags=["D"]) watson.stop() report = watson.report(arrow.now(), arrow.now(), projects=["foo"]) - assert len(report['projects']) == 1 + assert len(report["projects"]) == 1 report = watson.report(arrow.now(), arrow.now(), ignore_projects=["bar"]) - assert len(report['projects']) == 2 + assert len(report["projects"]) == 2 report = watson.report(arrow.now(), arrow.now(), tags=["A"]) - assert len(report['projects']) == 1 + assert len(report["projects"]) == 1 report = watson.report(arrow.now(), arrow.now(), ignore_tags=["D"]) - assert len(report['projects']) == 2 + assert len(report["projects"]) == 2 with pytest.raises(WatsonError): watson.report( @@ -823,42 +837,39 @@ def test_report(watson): def test_report_current(mocker, config_dir): - mocker.patch('arrow.utcnow', return_value=arrow.get(5000)) + mocker.patch("arrow.utcnow", return_value=arrow.get(5000)) - watson = Watson( - current={'project': 'foo', 'start': 4000}, - config_dir=config_dir - ) + watson = Watson(current={"project": "foo", "start": 4000}, config_dir=config_dir) for _ in range(2): report = watson.report( - arrow.utcnow(), arrow.utcnow(), current=True, projects=['foo'] + arrow.utcnow(), arrow.utcnow(), current=True, projects=["foo"] ) - assert len(report['projects']) == 1 - assert report['projects'][0]['name'] == 'foo' - assert report['projects'][0]['time'] == pytest.approx(1000) + assert len(report["projects"]) == 1 + assert report["projects"][0]["name"] == "foo" + assert report["projects"][0]["time"] == pytest.approx(1000) report = watson.report( - arrow.utcnow(), arrow.utcnow(), current=False, projects=['foo'] + arrow.utcnow(), arrow.utcnow(), current=False, projects=["foo"] ) - assert len(report['projects']) == 0 + assert len(report["projects"]) == 0 - report = watson.report( - arrow.utcnow(), arrow.utcnow(), projects=['foo'] - ) - assert len(report['projects']) == 0 + report = watson.report(arrow.utcnow(), arrow.utcnow(), projects=["foo"]) + assert len(report["projects"]) == 0 @pytest.mark.parametrize( - "date_as_unixtime,include_partial,sum_", ( + "date_as_unixtime,include_partial,sum_", + ( (3600 * 24, False, 0.0), (3600 * 48, False, 0.0), (3600 * 24, True, 7200.0), (3600 * 48, True, 3600.0), - ) + ), ) -def test_report_include_partial_frames(mocker, watson, date_as_unixtime, - include_partial, sum_): +def test_report_include_partial_frames( + mocker, watson, date_as_unixtime, include_partial, sum_ +): """Test report building with frames that cross report boundaries 1 event is added that has 2 hours in one day and 1 in the next. The @@ -867,18 +878,24 @@ def test_report_include_partial_frames(mocker, watson, date_as_unixtime, `include_partial=False` """ - content = json.dumps([[ - 3600 * 46, - 3600 * 49, - "programming", - "3e76c820909840f89cabaf106ab7d12a", - ["cli"], - 1548797432 - ]]) - mocker.patch('builtins.open', mocker.mock_open(read_data=content)) + content = json.dumps( + [ + [ + 3600 * 46, + 3600 * 49, + "programming", + "3e76c820909840f89cabaf106ab7d12a", + ["cli"], + 1548797432, + ] + ] + ) + mocker.patch("builtins.open", mocker.mock_open(read_data=content)) date = arrow.get(date_as_unixtime) report = watson.report( - from_=date, to=date, include_partial_frames=include_partial, + from_=date, + to=date, + include_partial_frames=include_partial, ) assert report["time"] == pytest.approx(sum_, abs=1e-3) @@ -890,31 +907,39 @@ def test_rename_project_with_time(watson): contains that project. """ watson.frames.add( - 'foo', 4001, 4002, ['some_tag'], - id='c76d1ad0282c429595cc566d7098c165', updated_at=4005 + "foo", + 4001, + 4002, + ["some_tag"], + id="c76d1ad0282c429595cc566d7098c165", + updated_at=4005, ) watson.frames.add( - 'bar', 4010, 4015, ['other_tag'], - id='eed598ff363d42658a095ae6c3ae1088', updated_at=4035 + "bar", + 4010, + 4015, + ["other_tag"], + id="eed598ff363d42658a095ae6c3ae1088", + updated_at=4035, ) watson.rename_project("foo", "baz") assert len(watson.frames) == 2 - assert watson.frames[0].id == 'c76d1ad0282c429595cc566d7098c165' - assert watson.frames[0].project == 'baz' + assert watson.frames[0].id == "c76d1ad0282c429595cc566d7098c165" + assert watson.frames[0].project == "baz" assert watson.frames[0].start.int_timestamp == 4001 assert watson.frames[0].stop.int_timestamp == 4002 - assert watson.frames[0].tags == ['some_tag'] + assert watson.frames[0].tags == ["some_tag"] # assert watson.frames[0].updated_at.int_timestamp == 9000 assert watson.frames[0].updated_at.int_timestamp > 4005 - assert watson.frames[1].id == 'eed598ff363d42658a095ae6c3ae1088' - assert watson.frames[1].project == 'bar' + assert watson.frames[1].id == "eed598ff363d42658a095ae6c3ae1088" + assert watson.frames[1].project == "bar" assert watson.frames[1].start.int_timestamp == 4010 assert watson.frames[1].stop.int_timestamp == 4015 - assert watson.frames[1].tags == ['other_tag'] + assert watson.frames[1].tags == ["other_tag"] assert watson.frames[1].updated_at.int_timestamp == 4035 @@ -924,33 +949,42 @@ def test_rename_tag_with_time(watson): contains that tag. """ watson.frames.add( - 'foo', 4001, 4002, ['some_tag'], - id='c76d1ad0282c429595cc566d7098c165', updated_at=4005 + "foo", + 4001, + 4002, + ["some_tag"], + id="c76d1ad0282c429595cc566d7098c165", + updated_at=4005, ) watson.frames.add( - 'bar', 4010, 4015, ['other_tag'], - id='eed598ff363d42658a095ae6c3ae1088', updated_at=4035 + "bar", + 4010, + 4015, + ["other_tag"], + id="eed598ff363d42658a095ae6c3ae1088", + updated_at=4035, ) watson.rename_tag("other_tag", "baz") assert len(watson.frames) == 2 - assert watson.frames[0].id == 'c76d1ad0282c429595cc566d7098c165' - assert watson.frames[0].project == 'foo' + assert watson.frames[0].id == "c76d1ad0282c429595cc566d7098c165" + assert watson.frames[0].project == "foo" assert watson.frames[0].start.int_timestamp == 4001 assert watson.frames[0].stop.int_timestamp == 4002 - assert watson.frames[0].tags == ['some_tag'] + assert watson.frames[0].tags == ["some_tag"] assert watson.frames[0].updated_at.int_timestamp == 4005 - assert watson.frames[1].id == 'eed598ff363d42658a095ae6c3ae1088' - assert watson.frames[1].project == 'bar' + assert watson.frames[1].id == "eed598ff363d42658a095ae6c3ae1088" + assert watson.frames[1].project == "bar" assert watson.frames[1].start.int_timestamp == 4010 assert watson.frames[1].stop.int_timestamp == 4015 - assert watson.frames[1].tags == ['baz'] + assert watson.frames[1].tags == ["baz"] # assert watson.frames[1].updated_at.int_timestamp == 9000 assert watson.frames[1].updated_at.int_timestamp > 4035 + # add @@ -958,12 +992,13 @@ def test_add_success(watson): """ Adding a new frame outside of live tracking successfully """ - watson.add(project="test_project", tags=['fuu', 'bar'], - from_date=6000, to_date=7000) + watson.add( + project="test_project", tags=["fuu", "bar"], from_date=6000, to_date=7000 + ) assert len(watson.frames) == 1 assert watson.frames[0].project == "test_project" - assert 'fuu' in watson.frames[0].tags + assert "fuu" in watson.frames[0].tags def test_add_failure(watson): @@ -972,18 +1007,20 @@ def test_add_failure(watson): to date is before from date """ with pytest.raises(WatsonError): - watson.add(project="test_project", tags=['fuu', 'bar'], - from_date=7000, to_date=6000) + watson.add( + project="test_project", tags=["fuu", "bar"], from_date=7000, to_date=6000 + ) def test_validate_report_options(watson): assert watson._validate_report_options(["project_foo"], None) assert watson._validate_report_options(None, ["project_foo"]) - assert not watson._validate_report_options(["project_foo"], - ["project_foo"]) + assert not watson._validate_report_options(["project_foo"], ["project_foo"]) assert watson._validate_report_options(["project_foo"], ["project_bar"]) - assert not watson._validate_report_options(["project_foo", "project_bar"], - ["project_foo"]) - assert not watson._validate_report_options(["project_foo", "project_bar"], - ["project_foo", "project_bar"]) + assert not watson._validate_report_options( + ["project_foo", "project_bar"], ["project_foo"] + ) + assert not watson._validate_report_options( + ["project_foo", "project_bar"], ["project_foo", "project_bar"] + ) assert watson._validate_report_options(None, None) diff --git a/watson/__init__.py b/watson/__init__.py index 7b408dd9..fe3ea832 100644 --- a/watson/__init__.py +++ b/watson/__init__.py @@ -1,4 +1,4 @@ from .watson import __version__ # noqa from .watson import Watson, WatsonError -__all__ = ['Watson', 'WatsonError'] +__all__ = ["Watson", "WatsonError"] diff --git a/watson/autocompletion.py b/watson/autocompletion.py index 9152b69f..aec22882 100644 --- a/watson/autocompletion.py +++ b/watson/autocompletion.py @@ -10,7 +10,7 @@ def _bypass_click_bug_to_ensure_watson(ctx): def get_project_or_task_completion(ctx, args, incomplete): """Function to autocomplete either organisations or tasks, depending on the - shape of the current argument.""" + shape of the current argument.""" assert isinstance(incomplete, str) @@ -47,9 +47,7 @@ def prepend_plus(tag_suggestions): _bypass_click_bug_to_ensure_watson(ctx) - project_is_completed = any( - tok.startswith("+") for tok in args + [incomplete] - ) + project_is_completed = any(tok.startswith("+") for tok in args + [incomplete]) if project_is_completed: incomplete_tag = get_incomplete_tag(args, incomplete) fixed_incomplete_tag = fix_broken_tag_parsing(incomplete_tag) diff --git a/watson/cli.py b/watson/cli.py index bd3bd527..db87b118 100644 --- a/watson/cli.py +++ b/watson/cli.py @@ -32,7 +32,8 @@ frames_to_json, get_frame_from_argument, get_start_time_for_period, - options, safe_save, + options, + safe_save, sorted_groupby, style, parse_tags, @@ -42,7 +43,7 @@ class MutuallyExclusiveOption(click.Option): def __init__(self, *args, **kwargs): - self.mutually_exclusive = set(kwargs.pop('mutually_exclusive', [])) + self.mutually_exclusive = set(kwargs.pop("mutually_exclusive", [])) super(MutuallyExclusiveOption, self).__init__(*args, **kwargs) def handle_parse_result(self, ctx, opts, args): @@ -51,20 +52,23 @@ def handle_parse_result(self, ctx, opts, args): self._raise_exclusive_error() if self.multiple and len(set(opts[self.name])) > 1: self._raise_exclusive_error() - return super(MutuallyExclusiveOption, self).handle_parse_result( - ctx, opts, args - ) + return super(MutuallyExclusiveOption, self).handle_parse_result(ctx, opts, args) def _raise_exclusive_error(self): # Use self.opts[-1] instead of self.name to handle options with a # different internal name. - self.mutually_exclusive.add(self.opts[-1].strip('-')) + self.mutually_exclusive.add(self.opts[-1].strip("-")) raise click.ClickException( style( - 'error', - 'The following options are mutually exclusive: ' - '{options}'.format(options=', '.join( - ['`--{}`'.format(_) for _ in self.mutually_exclusive])))) + "error", + "The following options are mutually exclusive: " + "{options}".format( + options=", ".join( + ["`--{}`".format(_) for _ in self.mutually_exclusive] + ) + ), + ) + ) def local_tz_info() -> datetime.tzinfo: @@ -83,15 +87,16 @@ def local_tz_info() -> datetime.tzinfo: class DateTimeParamType(click.ParamType): - name = 'datetime' + name = "datetime" def convert(self, value, param, ctx) -> arrow: if value: date = self._parse_multiformat(value) if date is None: raise click.UsageError( - "Could not match value '{}' to any supported date format" - .format(value) + "Could not match value '{}' to any supported date format".format( + value + ) ) # When we parse a date, we want to parse it in the timezone # expected by the user, so that midnight is midnight in the local @@ -101,24 +106,20 @@ def convert(self, value, param, ctx) -> arrow: # Add an offset to match the week beginning specified in the # configuration if param.name == "week": - week_start = ctx.obj.config.get( - "options", "week_start", "monday") - date = apply_weekday_offset( - start_time=date, week_start=week_start) + week_start = ctx.obj.config.get("options", "week_start", "monday") + date = apply_weekday_offset(start_time=date, week_start=week_start) return date def _parse_multiformat(self, value) -> arrow: date = None - for fmt in (None, 'HH:mm:ss', 'HH:mm'): + for fmt in (None, "HH:mm:ss", "HH:mm"): try: if fmt is None: date = arrow.get(value) else: date = arrow.get(value, fmt) date = arrow.now().replace( - hour=date.hour, - minute=date.minute, - second=date.second + hour=date.hour, minute=date.minute, second=date.second ) break except (ValueError, TypeError): @@ -135,14 +136,14 @@ def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except _watson.WatsonError as e: - raise click.ClickException(style('error', str(e))) + raise click.ClickException(style("error", str(e))) + return wrapper @click.group(cls=DYMGroup) -@click.version_option(version=_watson.__version__, prog_name='Watson') -@click.option('--color/--no-color', 'color', default=None, - help="(Don't) color output.") +@click.version_option(version=_watson.__version__, prog_name="Watson") +@click.option("--color/--no-color", "color", default=None, help="(Don't) color output.") @click.pass_context def cli(ctx, color): """ @@ -162,7 +163,7 @@ def cli(ctx, color): @cli.command() -@click.argument('command', required=False) +@click.argument("command", required=False) @click.pass_context def help(ctx, command): """ @@ -184,36 +185,65 @@ def _start(watson, project, tags, restart=False, start_at=None, gap=True): """ Start project with given list of tags and save status. """ - current = watson.start(project, tags, restart=restart, start_at=start_at, - gap=gap,) - click.echo("Starting project {}{} at {}".format( - style('project', project), - (" " if current['tags'] else "") + style('tags', current['tags']), - style('time', "{:HH:mm}".format(current['start'])) - )) + current = watson.start( + project, + tags, + restart=restart, + start_at=start_at, + gap=gap, + ) + click.echo( + "Starting project {}{} at {}".format( + style("project", project), + (" " if current["tags"] else "") + style("tags", current["tags"]), + style("time", "{:HH:mm}".format(current["start"])), + ) + ) watson.save() @cli.command() -@click.option('--at', 'at_', type=DateTime, default=None, - cls=MutuallyExclusiveOption, mutually_exclusive=['gap_'], - help=('Start frame at this time. Must be in ' - '(YYYY-MM-DDT)?HH:MM(:SS)? format.')) -@click.option('-g/-G', '--gap/--no-gap', 'gap_', is_flag=True, default=True, - cls=MutuallyExclusiveOption, mutually_exclusive=['at_'], - help=("(Don't) leave gap between end time of previous project " - "and start time of the current.")) -@click.argument('args', nargs=-1, - shell_complete=get_project_or_task_completion) -@click.option('-c', '--confirm-new-project', is_flag=True, default=False, - help="Confirm addition of new project.") -@click.option('-b', '--confirm-new-tag', is_flag=True, default=False, - help="Confirm creation of new tag.") +@click.option( + "--at", + "at_", + type=DateTime, + default=None, + cls=MutuallyExclusiveOption, + mutually_exclusive=["gap_"], + help=("Start frame at this time. Must be in " "(YYYY-MM-DDT)?HH:MM(:SS)? format."), +) +@click.option( + "-g/-G", + "--gap/--no-gap", + "gap_", + is_flag=True, + default=True, + cls=MutuallyExclusiveOption, + mutually_exclusive=["at_"], + help=( + "(Don't) leave gap between end time of previous project " + "and start time of the current." + ), +) +@click.argument("args", nargs=-1, shell_complete=get_project_or_task_completion) +@click.option( + "-c", + "--confirm-new-project", + is_flag=True, + default=False, + help="Confirm addition of new project.", +) +@click.option( + "-b", + "--confirm-new-tag", + is_flag=True, + default=False, + help="Confirm creation of new tag.", +) @click.pass_obj @click.pass_context @catch_watson_error -def start(ctx, watson, confirm_new_project, confirm_new_tag, args, at_, - gap_=True): +def start(ctx, watson, confirm_new_project, confirm_new_tag, args, at_, gap_=True): """ Start monitoring time for the given project. You can add tags indicating more specifically what you are working on with @@ -242,46 +272,50 @@ def start(ctx, watson, confirm_new_project, confirm_new_tag, args, at_, $ watson start apollo11 +module +brakes --no-gap Starting project apollo11 [module, brakes] at 16:34 """ - project = ' '.join( - itertools.takewhile(lambda s: not s.startswith('+'), args) - ) + project = " ".join(itertools.takewhile(lambda s: not s.startswith("+"), args)) if not project: raise click.ClickException("No project given.") # Confirm creation of new project if that option is set - if (watson.config.getboolean('options', 'confirm_new_project') or - confirm_new_project): + if ( + watson.config.getboolean("options", "confirm_new_project") + or confirm_new_project + ): confirm_project(project, watson.projects) # Parse all the tags tags = parse_tags(args) # Confirm creation of new tag(s) if that option is set - if (watson.config.getboolean('options', 'confirm_new_tag') or - confirm_new_tag): + if watson.config.getboolean("options", "confirm_new_tag") or confirm_new_tag: confirm_tags(tags, watson.tags) if project and watson.is_started and not gap_: current = watson.current - errmsg = ("Project '{}' is already started and '--no-gap' is passed. " - "Please stop manually.") - raise click.ClickException( - style( - 'error', errmsg.format(current['project']) - ) + errmsg = ( + "Project '{}' is already started and '--no-gap' is passed. " + "Please stop manually." ) + raise click.ClickException(style("error", errmsg.format(current["project"]))) - if (project and watson.is_started and - watson.config.getboolean('options', 'stop_on_start')): + if ( + project + and watson.is_started + and watson.config.getboolean("options", "stop_on_start") + ): ctx.invoke(stop) _start(watson, project, tags, start_at=at_, gap=gap_) -@cli.command(context_settings={'ignore_unknown_options': True}) -@click.option('--at', 'at_', type=DateTime, default=None, - help=('Stop frame at this time. Must be in ' - '(YYYY-MM-DDT)?HH:MM(:SS)? format.')) +@cli.command(context_settings={"ignore_unknown_options": True}) +@click.option( + "--at", + "at_", + type=DateTime, + default=None, + help=("Stop frame at this time. Must be in " "(YYYY-MM-DDT)?HH:MM(:SS)? format."), +) @click.pass_obj @catch_watson_error def stop(watson, at_): @@ -300,28 +334,49 @@ def stop(watson, at_): """ frame = watson.stop(stop_at=at_) output_str = "Stopping project {}{}, started {} and stopped {}. (id: {})" - click.echo(output_str.format( - style('project', frame.project), - (" " if frame.tags else "") + style('tags', frame.tags), - style('time', frame.start.humanize()), - style('time', frame.stop.humanize()), - style('short_id', frame.id), - )) + click.echo( + output_str.format( + style("project", frame.project), + (" " if frame.tags else "") + style("tags", frame.tags), + style("time", frame.start.humanize()), + style("time", frame.stop.humanize()), + style("short_id", frame.id), + ) + ) watson.save() -@cli.command(context_settings={'ignore_unknown_options': True}) -@click.option('--at', 'at_', type=DateTime, default=None, - cls=MutuallyExclusiveOption, mutually_exclusive=['gap_'], - help=('Start frame at this time. Must be in ' - '(YYYY-MM-DDT)?HH:MM(:SS)? format.')) -@click.option('-g/-G', '--gap/--no-gap', 'gap_', is_flag=True, default=True, - cls=MutuallyExclusiveOption, mutually_exclusive=['at_'], - help=("(Don't) leave gap between end time of previous project " - "and start time of the current.")) -@click.option('-s/-S', '--stop/--no-stop', 'stop_', default=None, - help="(Don't) Stop an already running project.") -@click.argument('id', default='-1', shell_complete=get_frames) +@cli.command(context_settings={"ignore_unknown_options": True}) +@click.option( + "--at", + "at_", + type=DateTime, + default=None, + cls=MutuallyExclusiveOption, + mutually_exclusive=["gap_"], + help=("Start frame at this time. Must be in " "(YYYY-MM-DDT)?HH:MM(:SS)? format."), +) +@click.option( + "-g/-G", + "--gap/--no-gap", + "gap_", + is_flag=True, + default=True, + cls=MutuallyExclusiveOption, + mutually_exclusive=["at_"], + help=( + "(Don't) leave gap between end time of previous project " + "and start time of the current." + ), +) +@click.option( + "-s/-S", + "--stop/--no-stop", + "stop_", + default=None, + help="(Don't) Stop an already running project.", +) +@click.argument("id", default="-1", shell_complete=get_frames) @click.pass_obj @click.pass_context @catch_watson_error @@ -359,35 +414,39 @@ def restart(ctx, watson, id, stop_, at_, gap_=True): """ if not watson.frames and not watson.is_started: raise click.ClickException( - style('error', "No frames recorded yet. It's time to create your " - "first one!")) + style( + "error", + "No frames recorded yet. It's time to create your " "first one!", + ) + ) if watson.is_started and not gap_: current = watson.current - errmsg = ("Project '{}' is already started and '--no-gap' is passed. " - "Please stop manually.") - raise click.ClickException( - style( - 'error', errmsg.format(current['project']) - ) + errmsg = ( + "Project '{}' is already started and '--no-gap' is passed. " + "Please stop manually." ) + raise click.ClickException(style("error", errmsg.format(current["project"]))) if watson.is_started: - if stop_ or (stop_ is None and - watson.config.getboolean('options', 'stop_on_restart')): + if stop_ or ( + stop_ is None and watson.config.getboolean("options", "stop_on_restart") + ): ctx.invoke(stop) else: # Raise error here, instead of in watson.start(), otherwise # will give misleading error if running frame is the first one - raise click.ClickException("{} {} {}".format( - style('error', "Project already started:"), - style('project', watson.current['project']), - style('tags', watson.current['tags']))) + raise click.ClickException( + "{} {} {}".format( + style("error", "Project already started:"), + style("project", watson.current["project"]), + style("tags", watson.current["tags"]), + ) + ) frame = get_frame_from_argument(watson, id) - _start(watson, frame.project, frame.tags, restart=True, start_at=at_, - gap=gap_) + _start(watson, frame.project, frame.tags, restart=True, start_at=at_, gap=gap_) @cli.command() @@ -399,20 +458,19 @@ def cancel(watson): not be recorded. """ old = watson.cancel() - click.echo("Canceling the timer for project {}{}".format( - style('project', old['project']), - (" " if old['tags'] else "") + style('tags', old['tags']) - )) + click.echo( + "Canceling the timer for project {}{}".format( + style("project", old["project"]), + (" " if old["tags"] else "") + style("tags", old["tags"]), + ) + ) watson.save() @cli.command() -@click.option('-p', '--project', is_flag=True, - help="only output project") -@click.option('-t', '--tags', is_flag=True, - help="only show tags") -@click.option('-e', '--elapsed', is_flag=True, - help="only show time elapsed") +@click.option("-p", "--project", is_flag=True, help="only output project") +@click.option("-t", "--tags", is_flag=True, help="only show tags") +@click.option("-e", "--elapsed", is_flag=True, help="only show time elapsed") @click.pass_obj @catch_watson_error def status(watson, project, tags, elapsed): @@ -442,110 +500,212 @@ def status(watson, project, tags, elapsed): current = watson.current if project: - click.echo("{}".format( - style('project', current['project']), - )) + click.echo( + "{}".format( + style("project", current["project"]), + ) + ) return if tags: - click.echo("{}".format( - style('tags', current['tags']) - )) + click.echo("{}".format(style("tags", current["tags"]))) return if elapsed: - click.echo("{}".format( - style('time', current['start'].humanize()) - )) + click.echo("{}".format(style("time", current["start"].humanize()))) return - datefmt = watson.config.get('options', 'date_format', '%Y.%m.%d') - timefmt = watson.config.get('options', 'time_format', '%H:%M:%S%z') - click.echo("Project {}{} started {} ({} {})".format( - style('project', current['project']), - (" " if current['tags'] else "") + style('tags', current['tags']), - style('time', current['start'].humanize()), - style('date', current['start'].strftime(datefmt)), - style('time', current['start'].strftime(timefmt)) - )) + datefmt = watson.config.get("options", "date_format", "%Y.%m.%d") + timefmt = watson.config.get("options", "time_format", "%H:%M:%S%z") + click.echo( + "Project {}{} started {} ({} {})".format( + style("project", current["project"]), + (" " if current["tags"] else "") + style("tags", current["tags"]), + style("time", current["start"].humanize()), + style("date", current["start"].strftime(datefmt)), + style("time", current["start"].strftime(timefmt)), + ) + ) -_SHORTCUT_OPTIONS = ['all', 'year', 'month', 'luna', 'week', 'day'] -_SHORTCUT_OPTIONS_VALUES = { - k: get_start_time_for_period(k) for k in _SHORTCUT_OPTIONS -} +_SHORTCUT_OPTIONS = ["all", "year", "month", "luna", "week", "day"] +_SHORTCUT_OPTIONS_VALUES = {k: get_start_time_for_period(k) for k in _SHORTCUT_OPTIONS} @cli.command() -@click.option('-c/-C', '--current/--no-current', 'current', default=None, - help="(Don't) include currently running frame in report.") -@click.option('-f', '--from', 'from_', cls=MutuallyExclusiveOption, - type=DateTime, default=arrow.now().shift(days=-7), - mutually_exclusive=_SHORTCUT_OPTIONS, - help="The date from when the report should start. Defaults " - "to seven days ago.") -@click.option('-t', '--to', cls=MutuallyExclusiveOption, type=DateTime, - default=arrow.now(), - mutually_exclusive=_SHORTCUT_OPTIONS, - help="The date at which the report should stop (inclusive). " - "Defaults to tomorrow.") -@click.option('-y', '--year', cls=MutuallyExclusiveOption, type=DateTime, - flag_value=_SHORTCUT_OPTIONS_VALUES['year'], - mutually_exclusive=['day', 'week', 'luna', 'month', 'all'], - help='Reports activity for the current year.') -@click.option('-m', '--month', cls=MutuallyExclusiveOption, type=DateTime, - flag_value=_SHORTCUT_OPTIONS_VALUES['month'], - mutually_exclusive=['day', 'week', 'luna', 'year', 'all'], - help='Reports activity for the current month.') -@click.option('-l', '--luna', cls=MutuallyExclusiveOption, type=DateTime, - flag_value=_SHORTCUT_OPTIONS_VALUES['luna'], - mutually_exclusive=['day', 'week', 'month', 'year', 'all'], - help='Reports activity for the current moon cycle.') -@click.option('-w', '--week', cls=MutuallyExclusiveOption, type=DateTime, - flag_value=_SHORTCUT_OPTIONS_VALUES['week'], - mutually_exclusive=['day', 'month', 'luna', 'year', 'all'], - help='Reports activity for the current week.') -@click.option('-d', '--day', cls=MutuallyExclusiveOption, type=DateTime, - flag_value=_SHORTCUT_OPTIONS_VALUES['day'], - mutually_exclusive=['week', 'month', 'luna', 'year', 'all'], - help='Reports activity for the current day.') -@click.option('-a', '--all', cls=MutuallyExclusiveOption, type=DateTime, - flag_value=_SHORTCUT_OPTIONS_VALUES['all'], - mutually_exclusive=['day', 'week', 'month', 'luna', 'year'], - help='Reports all activities.') -@click.option('-p', '--project', 'projects', shell_complete=get_projects, - multiple=True, - help="Reports activity only for the given project. You can add " - "other projects by using this option several times.") -@click.option('-T', '--tag', 'tags', shell_complete=get_tags, multiple=True, - help="Reports activity only for frames containing the given " - "tag. You can add several tags by using this option multiple " - "times") -@click.option('--ignore-project', 'ignore_projects', multiple=True, - help="Reports activity for all projects but the given ones. You " - "can ignore several projects by using the option multiple " - "times. Any given project will be ignored") -@click.option('--ignore-tag', 'ignore_tags', multiple=True, - help="Reports activity for all tags but the given ones. You can " - "ignore several tags by using the option multiple times. Any " - "given tag will be ignored") -@click.option('-j', '--json', 'output_format', cls=MutuallyExclusiveOption, - flag_value='json', mutually_exclusive=['csv'], - help="Format output in JSON instead of plain text") -@click.option('-s', '--csv', 'output_format', cls=MutuallyExclusiveOption, - flag_value='csv', mutually_exclusive=['json'], - help="Format output in CSV instead of plain text") -@click.option('--plain', 'output_format', cls=MutuallyExclusiveOption, - flag_value='plain', mutually_exclusive=['json', 'csv'], - default=True, hidden=True, - help="Format output in plain text (default)") -@click.option('-g/-G', '--pager/--no-pager', 'pager', default=None, - help="(Don't) view output through a pager.") +@click.option( + "-c/-C", + "--current/--no-current", + "current", + default=None, + help="(Don't) include currently running frame in report.", +) +@click.option( + "-f", + "--from", + "from_", + cls=MutuallyExclusiveOption, + type=DateTime, + default=arrow.now().shift(days=-7), + mutually_exclusive=_SHORTCUT_OPTIONS, + help="The date from when the report should start. Defaults " "to seven days ago.", +) +@click.option( + "-t", + "--to", + cls=MutuallyExclusiveOption, + type=DateTime, + default=arrow.now(), + mutually_exclusive=_SHORTCUT_OPTIONS, + help="The date at which the report should stop (inclusive). " + "Defaults to tomorrow.", +) +@click.option( + "-y", + "--year", + cls=MutuallyExclusiveOption, + type=DateTime, + flag_value=_SHORTCUT_OPTIONS_VALUES["year"], + mutually_exclusive=["day", "week", "luna", "month", "all"], + help="Reports activity for the current year.", +) +@click.option( + "-m", + "--month", + cls=MutuallyExclusiveOption, + type=DateTime, + flag_value=_SHORTCUT_OPTIONS_VALUES["month"], + mutually_exclusive=["day", "week", "luna", "year", "all"], + help="Reports activity for the current month.", +) +@click.option( + "-l", + "--luna", + cls=MutuallyExclusiveOption, + type=DateTime, + flag_value=_SHORTCUT_OPTIONS_VALUES["luna"], + mutually_exclusive=["day", "week", "month", "year", "all"], + help="Reports activity for the current moon cycle.", +) +@click.option( + "-w", + "--week", + cls=MutuallyExclusiveOption, + type=DateTime, + flag_value=_SHORTCUT_OPTIONS_VALUES["week"], + mutually_exclusive=["day", "month", "luna", "year", "all"], + help="Reports activity for the current week.", +) +@click.option( + "-d", + "--day", + cls=MutuallyExclusiveOption, + type=DateTime, + flag_value=_SHORTCUT_OPTIONS_VALUES["day"], + mutually_exclusive=["week", "month", "luna", "year", "all"], + help="Reports activity for the current day.", +) +@click.option( + "-a", + "--all", + cls=MutuallyExclusiveOption, + type=DateTime, + flag_value=_SHORTCUT_OPTIONS_VALUES["all"], + mutually_exclusive=["day", "week", "month", "luna", "year"], + help="Reports all activities.", +) +@click.option( + "-p", + "--project", + "projects", + shell_complete=get_projects, + multiple=True, + help="Reports activity only for the given project. You can add " + "other projects by using this option several times.", +) +@click.option( + "-T", + "--tag", + "tags", + shell_complete=get_tags, + multiple=True, + help="Reports activity only for frames containing the given " + "tag. You can add several tags by using this option multiple " + "times", +) +@click.option( + "--ignore-project", + "ignore_projects", + multiple=True, + help="Reports activity for all projects but the given ones. You " + "can ignore several projects by using the option multiple " + "times. Any given project will be ignored", +) +@click.option( + "--ignore-tag", + "ignore_tags", + multiple=True, + help="Reports activity for all tags but the given ones. You can " + "ignore several tags by using the option multiple times. Any " + "given tag will be ignored", +) +@click.option( + "-j", + "--json", + "output_format", + cls=MutuallyExclusiveOption, + flag_value="json", + mutually_exclusive=["csv"], + help="Format output in JSON instead of plain text", +) +@click.option( + "-s", + "--csv", + "output_format", + cls=MutuallyExclusiveOption, + flag_value="csv", + mutually_exclusive=["json"], + help="Format output in CSV instead of plain text", +) +@click.option( + "--plain", + "output_format", + cls=MutuallyExclusiveOption, + flag_value="plain", + mutually_exclusive=["json", "csv"], + default=True, + hidden=True, + help="Format output in plain text (default)", +) +@click.option( + "-g/-G", + "--pager/--no-pager", + "pager", + default=None, + help="(Don't) view output through a pager.", +) @click.pass_obj @catch_watson_error -def report(watson, current, from_, to, projects, tags, ignore_projects, - ignore_tags, year, month, week, day, luna, all, output_format, - pager, aggregated=False, include_partial_frames=True): +def report( + watson, + current, + from_, + to, + projects, + tags, + ignore_projects, + ignore_tags, + year, + month, + week, + day, + luna, + all, + output_format, + pager, + aggregated=False, + include_partial_frames=True, +): """ Display a report of the time spent on each project. @@ -655,36 +815,48 @@ def report(watson, current, from_, to, projects, tags, ignore_projects, # if the report is an aggregate report, add whitespace using this # aggregate tab which will be prepended to the project name if aggregated: - tab = ' ' + tab = " " else: - tab = '' - - report = watson.report(from_, to, current, projects, tags, - ignore_projects, ignore_tags, - year=year, month=month, week=week, day=day, - luna=luna, all=all, - include_partial_frames=include_partial_frames) + tab = "" + + report = watson.report( + from_, + to, + current, + projects, + tags, + ignore_projects, + ignore_tags, + year=year, + month=month, + week=week, + day=day, + luna=luna, + all=all, + include_partial_frames=include_partial_frames, + ) - if 'json' in output_format and not aggregated: - click.echo(json.dumps(report, indent=4, sort_keys=True, - default=json_arrow_encoder)) + if "json" in output_format and not aggregated: + click.echo( + json.dumps(report, indent=4, sort_keys=True, default=json_arrow_encoder) + ) return - elif 'csv' in output_format and not aggregated: + elif "csv" in output_format and not aggregated: click.echo(build_csv(flatten_report_for_csv(report))) return - elif 'plain' not in output_format and aggregated: + elif "plain" not in output_format and aggregated: return report lines = [] # use the pager, or print directly to the terminal - if pager or (pager is None and - watson.config.getboolean('options', 'pager', True)): + if pager or (pager is None and watson.config.getboolean("options", "pager", True)): def _print(line): lines.append(line) def _final_print(lines): - click.echo_via_pager('\n'.join(lines)) + click.echo_via_pager("\n".join(lines)) + elif aggregated: def _print(line): @@ -692,6 +864,7 @@ def _print(line): def _final_print(lines): pass + else: def _print(line): @@ -702,49 +875,58 @@ def _final_print(lines): # handle special title formatting for aggregate reports if aggregated: - _print('{} - {}'.format( - style('date', '{:ddd DD MMMM YYYY}'.format( - report['timespan']['from'] - )), - style('time', '{}'.format(format_timedelta( - datetime.timedelta(seconds=report['time']) - ))) - )) + _print( + "{} - {}".format( + style("date", "{:ddd DD MMMM YYYY}".format(report["timespan"]["from"])), + style( + "time", + "{}".format( + format_timedelta(datetime.timedelta(seconds=report["time"])) + ), + ), + ) + ) else: - _print('{} -> {}\n'.format( - style('date', '{:ddd DD MMMM YYYY}'.format( - report['timespan']['from'] - )), - style('date', '{:ddd DD MMMM YYYY}'.format( - report['timespan']['to'] - )) - )) + _print( + "{} -> {}\n".format( + style("date", "{:ddd DD MMMM YYYY}".format(report["timespan"]["from"])), + style("date", "{:ddd DD MMMM YYYY}".format(report["timespan"]["to"])), + ) + ) - projects = report['projects'] + projects = report["projects"] for project in projects: - _print('{tab}{project} - {time}'.format( - tab=tab, - time=style('time', format_timedelta( - datetime.timedelta(seconds=project['time']) - )), - project=style('project', project['name']) - )) - - tags = project['tags'] + _print( + "{tab}{project} - {time}".format( + tab=tab, + time=style( + "time", + format_timedelta(datetime.timedelta(seconds=project["time"])), + ), + project=style("project", project["name"]), + ) + ) + + tags = project["tags"] if tags: - longest_tag = max(len(tag) for tag in tags or ['']) + longest_tag = max(len(tag) for tag in tags or [""]) for tag in tags: - _print('\t[{tag} {time}]'.format( - time=style('time', '{:>11}'.format(format_timedelta( - datetime.timedelta(seconds=tag['time']) - ))), - tag=style('tag', '{:<{}}'.format( - tag['name'], longest_tag - )), - )) + _print( + "\t[{tag} {time}]".format( + time=style( + "time", + "{:>11}".format( + format_timedelta( + datetime.timedelta(seconds=tag["time"]) + ) + ), + ), + tag=style("tag", "{:<{}}".format(tag["name"], longest_tag)), + ) + ) _print("") # if this is a report invoked from `aggregate` return the lines; do not @@ -752,53 +934,106 @@ def _final_print(lines): if aggregated: return lines - _print('Total: {}'.format( - style('time', '{}'.format(format_timedelta( - datetime.timedelta(seconds=report['time']) - ))) - )) + _print( + "Total: {}".format( + style( + "time", + "{}".format( + format_timedelta(datetime.timedelta(seconds=report["time"])) + ), + ) + ) + ) _final_print(lines) @cli.command() -@click.option('-c/-C', '--current/--no-current', 'current', default=None, - help="(Don't) include currently running frame in report.") -@click.option('-f', '--from', 'from_', cls=MutuallyExclusiveOption, - type=DateTime, default=arrow.now().shift(days=-7), - mutually_exclusive=_SHORTCUT_OPTIONS, - help="The date from when the report should start. Defaults " - "to seven days ago.") -@click.option('-t', '--to', cls=MutuallyExclusiveOption, type=DateTime, - default=arrow.now(), - mutually_exclusive=_SHORTCUT_OPTIONS, - help="The date at which the report should stop (inclusive). " - "Defaults to tomorrow.") -@click.option('-p', '--project', 'projects', shell_complete=get_projects, - multiple=True, - help="Reports activity only for the given project. You can add " - "other projects by using this option several times.") -@click.option('-T', '--tag', 'tags', shell_complete=get_tags, multiple=True, - help="Reports activity only for frames containing the given " - "tag. You can add several tags by using this option multiple " - "times") -@click.option('-j', '--json', 'output_format', cls=MutuallyExclusiveOption, - flag_value='json', mutually_exclusive=['csv'], - help="Format output in JSON instead of plain text") -@click.option('-s', '--csv', 'output_format', cls=MutuallyExclusiveOption, - flag_value='csv', mutually_exclusive=['json'], - help="Format output in CSV instead of plain text") -@click.option('--plain', 'output_format', cls=MutuallyExclusiveOption, - flag_value='plain', mutually_exclusive=['json', 'csv'], - default=True, hidden=True, - help="Format output in plain text (default)") -@click.option('-g/-G', '--pager/--no-pager', 'pager', default=None, - help="(Don't) view output through a pager.") +@click.option( + "-c/-C", + "--current/--no-current", + "current", + default=None, + help="(Don't) include currently running frame in report.", +) +@click.option( + "-f", + "--from", + "from_", + cls=MutuallyExclusiveOption, + type=DateTime, + default=arrow.now().shift(days=-7), + mutually_exclusive=_SHORTCUT_OPTIONS, + help="The date from when the report should start. Defaults " "to seven days ago.", +) +@click.option( + "-t", + "--to", + cls=MutuallyExclusiveOption, + type=DateTime, + default=arrow.now(), + mutually_exclusive=_SHORTCUT_OPTIONS, + help="The date at which the report should stop (inclusive). " + "Defaults to tomorrow.", +) +@click.option( + "-p", + "--project", + "projects", + shell_complete=get_projects, + multiple=True, + help="Reports activity only for the given project. You can add " + "other projects by using this option several times.", +) +@click.option( + "-T", + "--tag", + "tags", + shell_complete=get_tags, + multiple=True, + help="Reports activity only for frames containing the given " + "tag. You can add several tags by using this option multiple " + "times", +) +@click.option( + "-j", + "--json", + "output_format", + cls=MutuallyExclusiveOption, + flag_value="json", + mutually_exclusive=["csv"], + help="Format output in JSON instead of plain text", +) +@click.option( + "-s", + "--csv", + "output_format", + cls=MutuallyExclusiveOption, + flag_value="csv", + mutually_exclusive=["json"], + help="Format output in CSV instead of plain text", +) +@click.option( + "--plain", + "output_format", + cls=MutuallyExclusiveOption, + flag_value="plain", + mutually_exclusive=["json", "csv"], + default=True, + hidden=True, + help="Format output in plain text (default)", +) +@click.option( + "-g/-G", + "--pager/--no-pager", + "pager", + default=None, + help="(Don't) view output through a pager.", +) @click.pass_obj @click.pass_context @catch_watson_error -def aggregate(ctx, watson, current, from_, to, projects, tags, output_format, - pager): +def aggregate(ctx, watson, current, from_, to, projects, tags, output_format, pager): """ Display a report of the time spent on each project aggregated by day. @@ -874,104 +1109,220 @@ def aggregate(ctx, watson, current, from_, to, projects, tags, output_format, for i in range(delta + 1): offset = datetime.timedelta(days=i) from_offset = from_ + offset - output = ctx.invoke(report, current=current, from_=from_offset, - to=from_offset, projects=projects, tags=tags, - output_format=output_format, - pager=pager, aggregated=True, - include_partial_frames=True) + output = ctx.invoke( + report, + current=current, + from_=from_offset, + to=from_offset, + projects=projects, + tags=tags, + output_format=output_format, + pager=pager, + aggregated=True, + include_partial_frames=True, + ) - if 'json' in output_format: + if "json" in output_format: lines.append(output) - elif 'csv' in output_format: + elif "csv" in output_format: lines.extend(flatten_report_for_csv(output)) else: # if there is no activity for the day, append a newline # this ensures even spacing throughout the report if (len(output)) == 1: - output[0] += '\n' + output[0] += "\n" - lines.append('\n'.join(output)) + lines.append("\n".join(output)) - if 'json' in output_format: - click.echo(json.dumps(lines, indent=4, sort_keys=True, - default=json_arrow_encoder)) - elif 'csv' in output_format: + if "json" in output_format: + click.echo( + json.dumps(lines, indent=4, sort_keys=True, default=json_arrow_encoder) + ) + elif "csv" in output_format: click.echo(build_csv(lines)) - elif pager or (pager is None and - watson.config.getboolean('options', 'pager', True)): - click.echo_via_pager('\n\n'.join(lines)) + elif pager or ( + pager is None and watson.config.getboolean("options", "pager", True) + ): + click.echo_via_pager("\n\n".join(lines)) else: - click.echo('\n\n'.join(lines)) + click.echo("\n\n".join(lines)) @cli.command() -@click.option('-c/-C', '--current/--no-current', 'current', default=None, - help="(Don't) include currently running frame in output.") -@click.option('-r/-R', '--reverse/--no-reverse', 'reverse', default=None, - help="(Don't) reverse the order of the days in output.") -@click.option('-f', '--from', 'from_', type=DateTime, - default=arrow.now().shift(days=-7), - help="The date from when the log should start. Defaults " - "to seven days ago.") -@click.option('-t', '--to', type=DateTime, default=arrow.now(), - help="The date at which the log should stop (inclusive). " - "Defaults to tomorrow.") -@click.option('-y', '--year', cls=MutuallyExclusiveOption, type=DateTime, - flag_value=_SHORTCUT_OPTIONS_VALUES['year'], - mutually_exclusive=['day', 'week', 'month', 'all'], - help='Reports activity for the current year.') -@click.option('-m', '--month', cls=MutuallyExclusiveOption, type=DateTime, - flag_value=_SHORTCUT_OPTIONS_VALUES['month'], - mutually_exclusive=['day', 'week', 'year', 'all'], - help='Reports activity for the current month.') -@click.option('-l', '--luna', cls=MutuallyExclusiveOption, type=DateTime, - flag_value=_SHORTCUT_OPTIONS_VALUES['luna'], - mutually_exclusive=['day', 'week', 'month', 'year', 'all'], - help='Reports activity for the current moon cycle.') -@click.option('-w', '--week', cls=MutuallyExclusiveOption, type=DateTime, - flag_value=_SHORTCUT_OPTIONS_VALUES['week'], - mutually_exclusive=['day', 'month', 'year', 'all'], - help='Reports activity for the current week.') -@click.option('-d', '--day', cls=MutuallyExclusiveOption, type=DateTime, - flag_value=_SHORTCUT_OPTIONS_VALUES['day'], - mutually_exclusive=['week', 'month', 'year', 'all'], - help='Reports activity for the current day.') -@click.option('-a', '--all', cls=MutuallyExclusiveOption, type=DateTime, - flag_value=_SHORTCUT_OPTIONS_VALUES['all'], - mutually_exclusive=['day', 'week', 'month', 'year'], - help='Reports all activities.') -@click.option('-p', '--project', 'projects', shell_complete=get_projects, - multiple=True, - help="Logs activity only for the given project. You can add " - "other projects by using this option several times.") -@click.option('-T', '--tag', 'tags', shell_complete=get_tags, multiple=True, - help="Logs activity only for frames containing the given " - "tag. You can add several tags by using this option multiple " - "times") -@click.option('--ignore-project', 'ignore_projects', multiple=True, - help="Logs activity for all projects but the given ones. You " - "can ignore several projects by using the option multiple " - "times. Any given project will be ignored") -@click.option('--ignore-tag', 'ignore_tags', multiple=True, - help="Logs activity for all tags but the given ones. You can " - "ignore several tags by using the option multiple times. Any " - "given tag will be ignored") -@click.option('-j', '--json', 'output_format', cls=MutuallyExclusiveOption, - flag_value='json', mutually_exclusive=['csv'], - help="Format output in JSON instead of plain text") -@click.option('-s', '--csv', 'output_format', cls=MutuallyExclusiveOption, - flag_value='csv', mutually_exclusive=['json'], - help="Format output in CSV instead of plain text") -@click.option('--plain', 'output_format', cls=MutuallyExclusiveOption, - flag_value='plain', mutually_exclusive=['json', 'csv'], - default=True, hidden=True, - help="Format output in plain text (default)") -@click.option('-g/-G', '--pager/--no-pager', 'pager', default=None, - help="(Don't) view output through a pager.") +@click.option( + "-c/-C", + "--current/--no-current", + "current", + default=None, + help="(Don't) include currently running frame in output.", +) +@click.option( + "-r/-R", + "--reverse/--no-reverse", + "reverse", + default=None, + help="(Don't) reverse the order of the days in output.", +) +@click.option( + "-f", + "--from", + "from_", + type=DateTime, + default=arrow.now().shift(days=-7), + help="The date from when the log should start. Defaults " "to seven days ago.", +) +@click.option( + "-t", + "--to", + type=DateTime, + default=arrow.now(), + help="The date at which the log should stop (inclusive). " "Defaults to tomorrow.", +) +@click.option( + "-y", + "--year", + cls=MutuallyExclusiveOption, + type=DateTime, + flag_value=_SHORTCUT_OPTIONS_VALUES["year"], + mutually_exclusive=["day", "week", "month", "all"], + help="Reports activity for the current year.", +) +@click.option( + "-m", + "--month", + cls=MutuallyExclusiveOption, + type=DateTime, + flag_value=_SHORTCUT_OPTIONS_VALUES["month"], + mutually_exclusive=["day", "week", "year", "all"], + help="Reports activity for the current month.", +) +@click.option( + "-l", + "--luna", + cls=MutuallyExclusiveOption, + type=DateTime, + flag_value=_SHORTCUT_OPTIONS_VALUES["luna"], + mutually_exclusive=["day", "week", "month", "year", "all"], + help="Reports activity for the current moon cycle.", +) +@click.option( + "-w", + "--week", + cls=MutuallyExclusiveOption, + type=DateTime, + flag_value=_SHORTCUT_OPTIONS_VALUES["week"], + mutually_exclusive=["day", "month", "year", "all"], + help="Reports activity for the current week.", +) +@click.option( + "-d", + "--day", + cls=MutuallyExclusiveOption, + type=DateTime, + flag_value=_SHORTCUT_OPTIONS_VALUES["day"], + mutually_exclusive=["week", "month", "year", "all"], + help="Reports activity for the current day.", +) +@click.option( + "-a", + "--all", + cls=MutuallyExclusiveOption, + type=DateTime, + flag_value=_SHORTCUT_OPTIONS_VALUES["all"], + mutually_exclusive=["day", "week", "month", "year"], + help="Reports all activities.", +) +@click.option( + "-p", + "--project", + "projects", + shell_complete=get_projects, + multiple=True, + help="Logs activity only for the given project. You can add " + "other projects by using this option several times.", +) +@click.option( + "-T", + "--tag", + "tags", + shell_complete=get_tags, + multiple=True, + help="Logs activity only for frames containing the given " + "tag. You can add several tags by using this option multiple " + "times", +) +@click.option( + "--ignore-project", + "ignore_projects", + multiple=True, + help="Logs activity for all projects but the given ones. You " + "can ignore several projects by using the option multiple " + "times. Any given project will be ignored", +) +@click.option( + "--ignore-tag", + "ignore_tags", + multiple=True, + help="Logs activity for all tags but the given ones. You can " + "ignore several tags by using the option multiple times. Any " + "given tag will be ignored", +) +@click.option( + "-j", + "--json", + "output_format", + cls=MutuallyExclusiveOption, + flag_value="json", + mutually_exclusive=["csv"], + help="Format output in JSON instead of plain text", +) +@click.option( + "-s", + "--csv", + "output_format", + cls=MutuallyExclusiveOption, + flag_value="csv", + mutually_exclusive=["json"], + help="Format output in CSV instead of plain text", +) +@click.option( + "--plain", + "output_format", + cls=MutuallyExclusiveOption, + flag_value="plain", + mutually_exclusive=["json", "csv"], + default=True, + hidden=True, + help="Format output in plain text (default)", +) +@click.option( + "-g/-G", + "--pager/--no-pager", + "pager", + default=None, + help="(Don't) view output through a pager.", +) @click.pass_obj @catch_watson_error -def log(watson, current, reverse, from_, to, projects, tags, ignore_projects, - ignore_tags, year, month, week, day, luna, all, output_format, pager): +def log( + watson, + current, + reverse, + from_, + to, + projects, + tags, + ignore_projects, + ignore_tags, + year, + month, + week, + day, + luna, + all, + output_format, + pager, +): """ Display each recorded session during the given timespan. @@ -1035,63 +1386,65 @@ def log(watson, current, reverse, from_, to, projects, tags, ignore_projects, 02cb269,2014-04-16 09:53,2014-04-16 12:43,apollo11,wheels 1070ddb,2014-04-16 13:48,2014-04-16 16:17,voyager1,"antenna, sensors" """ # noqa - for start_time in (_ for _ in [day, week, month, luna, year, all] - if _ is not None): + for start_time in (_ for _ in [day, week, month, luna, year, all] if _ is not None): from_ = start_time if from_ > to: raise click.ClickException("'from' must be anterior to 'to'") - if bool(projects and ignore_projects and - set(projects).intersection(set(ignore_projects))): - raise click.ClickException( - "given projects can't be ignored at the same time") + if bool( + projects + and ignore_projects + and set(projects).intersection(set(ignore_projects)) + ): + raise click.ClickException("given projects can't be ignored at the same time") if bool(tags and ignore_tags and set(tags).intersection(set(ignore_tags))): - raise click.ClickException( - "given tags can't be ignored at the same time") + raise click.ClickException("given tags can't be ignored at the same time") if watson.current: - if current or (current is None and - watson.config.getboolean('options', 'log_current')): + if current or ( + current is None and watson.config.getboolean("options", "log_current") + ): cur = watson.current - watson.frames.add(cur['project'], cur['start'], arrow.utcnow(), - cur['tags'], id="current") + watson.frames.add( + cur["project"], cur["start"], arrow.utcnow(), cur["tags"], id="current" + ) if reverse is None: - reverse = watson.config.getboolean('options', 'reverse_log', True) + reverse = watson.config.getboolean("options", "reverse_log", True) span = watson.frames.span(from_, to) filtered_frames = watson.frames.filter( - projects=projects or None, tags=tags or None, + projects=projects or None, + tags=tags or None, ignore_projects=ignore_projects or None, - ignore_tags=ignore_tags or None, span=span + ignore_tags=ignore_tags or None, + span=span, ) - if 'json' in output_format: + if "json" in output_format: click.echo(frames_to_json(filtered_frames)) return - if 'csv' in output_format: + if "csv" in output_format: click.echo(frames_to_csv(filtered_frames)) return frames_by_day = sorted_groupby( - filtered_frames, - operator.attrgetter('day'), - reverse=reverse + filtered_frames, operator.attrgetter("day"), reverse=reverse ) lines = [] # use the pager, or print directly to the terminal - if pager or (pager is None and - watson.config.getboolean('options', 'pager', True)): + if pager or (pager is None and watson.config.getboolean("options", "pager", True)): def _print(line): lines.append(line) def _final_print(lines): - click.echo_via_pager('\n'.join(lines)) + click.echo_via_pager("\n".join(lines)) + else: def _print(line): @@ -1102,36 +1455,37 @@ def _final_print(lines): for i, (day, frames) in enumerate(frames_by_day): if i != 0: - _print('') + _print("") - frames = sorted(frames, key=operator.attrgetter('start')) + frames = sorted(frames, key=operator.attrgetter("start")) longest_project = max(len(frame.project) for frame in frames) daily_total = reduce( - operator.add, - (frame.stop - frame.start for frame in frames) + operator.add, (frame.stop - frame.start for frame in frames) ) _print( "{date} ({daily_total})".format( - date=style('date', "{:dddd DD MMMM YYYY}".format(day)), - daily_total=style('time', format_timedelta(daily_total)) + date=style("date", "{:dddd DD MMMM YYYY}".format(day)), + daily_total=style("time", format_timedelta(daily_total)), ) ) - _print("\n".join( - "\t{id} {start} to {stop} {delta:>11} {project}{tags}".format( - delta=format_timedelta(frame.stop - frame.start), - project=style('project', '{:>{}}'.format( - frame.project, longest_project - )), - tags=(" "*2 if frame.tags else "") + style('tags', frame.tags), - start=style('time', '{:HH:mm}'.format(frame.start)), - stop=style('time', '{:HH:mm}'.format(frame.stop)), - id=style('short_id', frame.id) + _print( + "\n".join( + "\t{id} {start} to {stop} {delta:>11} {project}{tags}".format( + delta=format_timedelta(frame.stop - frame.start), + project=style( + "project", "{:>{}}".format(frame.project, longest_project) + ), + tags=(" " * 2 if frame.tags else "") + style("tags", frame.tags), + start=style("time", "{:HH:mm}".format(frame.start)), + stop=style("time", "{:HH:mm}".format(frame.stop)), + id=style("short_id", frame.id), + ) + for frame in frames ) - for frame in frames - )) + ) _final_print(lines) @@ -1153,7 +1507,7 @@ def projects(watson): voyager2 """ for project in watson.projects: - click.echo(style('project', project)) + click.echo(style("project", project)) @cli.command() @@ -1181,7 +1535,7 @@ def tags(watson): wheels """ for tag in watson.tags: - click.echo(style('tag', tag)) + click.echo(style("tag", tag)) @cli.command() @@ -1201,20 +1555,40 @@ def frames(watson): [...] """ for frame in watson.frames: - click.echo(style('short_id', frame.id)) - - -@cli.command(context_settings={'ignore_unknown_options': True}) -@click.argument('args', nargs=-1, - shell_complete=get_project_or_task_completion) -@click.option('-f', '--from', 'from_', required=True, type=DateTime, - help="Date and time of start of tracked activity") -@click.option('-t', '--to', required=True, type=DateTime, - help="Date and time of end of tracked activity") -@click.option('-c', '--confirm-new-project', is_flag=True, default=False, - help="Confirm addition of new project.") -@click.option('-b', '--confirm-new-tag', is_flag=True, default=False, - help="Confirm creation of new tag.") + click.echo(style("short_id", frame.id)) + + +@cli.command(context_settings={"ignore_unknown_options": True}) +@click.argument("args", nargs=-1, shell_complete=get_project_or_task_completion) +@click.option( + "-f", + "--from", + "from_", + required=True, + type=DateTime, + help="Date and time of start of tracked activity", +) +@click.option( + "-t", + "--to", + required=True, + type=DateTime, + help="Date and time of end of tracked activity", +) +@click.option( + "-c", + "--confirm-new-project", + is_flag=True, + default=False, + help="Confirm addition of new project.", +) +@click.option( + "-b", + "--confirm-new-tag", + is_flag=True, + default=False, + help="Confirm creation of new tag.", +) @click.pass_obj @catch_watson_error def add(watson, args, from_, to, confirm_new_project, confirm_new_tag): @@ -1228,45 +1602,54 @@ def add(watson, args, from_, to, confirm_new_project, confirm_new_tag): programming +addfeature """ # parse project name from args - project = ' '.join( - itertools.takewhile(lambda s: not s.startswith('+'), args) - ) + project = " ".join(itertools.takewhile(lambda s: not s.startswith("+"), args)) if not project: raise click.ClickException("No project given.") # Confirm creation of new project if that option is set - if (watson.config.getboolean('options', 'confirm_new_project') or - confirm_new_project): + if ( + watson.config.getboolean("options", "confirm_new_project") + or confirm_new_project + ): confirm_project(project, watson.projects) # Parse all the tags tags = parse_tags(args) # Confirm creation of new tag(s) if that option is set - if (watson.config.getboolean('options', 'confirm_new_tag') or - confirm_new_tag): + if watson.config.getboolean("options", "confirm_new_tag") or confirm_new_tag: confirm_tags(tags, watson.tags) # add a new frame, call watson save to update state files frame = watson.add(project=project, tags=tags, from_date=from_, to_date=to) click.echo( "Adding project {}{}, started {} and stopped {}. (id: {})".format( - style('project', frame.project), - (" " if frame.tags else "") + style('tags', frame.tags), - style('time', frame.start.humanize()), - style('time', frame.stop.humanize()), - style('short_id', frame.id) + style("project", frame.project), + (" " if frame.tags else "") + style("tags", frame.tags), + style("time", frame.start.humanize()), + style("time", frame.stop.humanize()), + style("short_id", frame.id), ) ) watson.save() -@cli.command(context_settings={'ignore_unknown_options': True}) -@click.option('-c', '--confirm-new-project', is_flag=True, default=False, - help="Confirm addition of new project.") -@click.option('-b', '--confirm-new-tag', is_flag=True, default=False, - help="Confirm creation of new tag.") -@click.argument('id', required=False, shell_complete=get_frames) +@cli.command(context_settings={"ignore_unknown_options": True}) +@click.option( + "-c", + "--confirm-new-project", + is_flag=True, + default=False, + help="Confirm addition of new project.", +) +@click.option( + "-b", + "--confirm-new-tag", + is_flag=True, + default=False, + help="Confirm creation of new tag.", +) +@click.argument("id", required=False, shell_complete=get_frames) @click.pass_obj @catch_watson_error def edit(watson, confirm_new_project, confirm_new_tag, id): @@ -1284,33 +1667,41 @@ def edit(watson, confirm_new_project, confirm_new_tag, id): variables (in that order) and defaults to `notepad` on Windows systems and to `vim`, `nano`, or `vi` (first one found) on all other systems. """ - date_format = 'YYYY-MM-DD' - time_format = 'HH:mm:ss' - datetime_format = '{} {}'.format(date_format, time_format) + date_format = "YYYY-MM-DD" + time_format = "HH:mm:ss" + datetime_format = "{} {}".format(date_format, time_format) local_tz = local_tz_info() if id: frame = get_frame_from_argument(watson, id) id = frame.id elif watson.is_started: - frame = Frame(watson.current['start'], None, watson.current['project'], - None, watson.current['tags']) + frame = Frame( + watson.current["start"], + None, + watson.current["project"], + None, + watson.current["tags"], + ) elif watson.frames: frame = watson.frames[-1] id = frame.id else: raise click.ClickException( - style('error', "No frames recorded yet. It's time to create your " - "first one!")) + style( + "error", + "No frames recorded yet. It's time to create your " "first one!", + ) + ) data = { - 'start': frame.start.format(datetime_format), - 'project': frame.project, - 'tags': frame.tags, + "start": frame.start.format(datetime_format), + "project": frame.project, + "tags": frame.tags, } if id: - data['stop'] = frame.stop.format(datetime_format) + data["stop"] = frame.stop.format(datetime_format) text = json.dumps(data, indent=4, sort_keys=True, ensure_ascii=False) @@ -1320,7 +1711,7 @@ def edit(watson, confirm_new_project, confirm_new_tag, id): # enter into while loop until successful and validated # edit has been performed while True: - output = click.edit(text, extension='.json') + output = click.edit(text, extension=".json") if not output: click.echo("No change made.") @@ -1328,25 +1719,36 @@ def edit(watson, confirm_new_project, confirm_new_tag, id): try: data = json.loads(output) - project = data['project'] + project = data["project"] # Confirm creation of new project if that option is set - if (watson.config.getboolean('options', 'confirm_new_project') or - confirm_new_project): + if ( + watson.config.getboolean("options", "confirm_new_project") + or confirm_new_project + ): confirm_project(project, watson.projects) - tags = data['tags'] + tags = data["tags"] # Confirm creation of new tag(s) if that option is set - if (watson.config.getboolean('options', 'confirm_new_tag') or - confirm_new_tag): + if ( + watson.config.getboolean("options", "confirm_new_tag") + or confirm_new_tag + ): confirm_tags(tags, watson.tags) - start = arrow.get(data['start'], datetime_format).replace( - tzinfo=local_tz).to('utc') - stop = arrow.get(data['stop'], datetime_format).replace( - tzinfo=local_tz).to('utc') if id else None + start = ( + arrow.get(data["start"], datetime_format) + .replace(tzinfo=local_tz) + .to("utc") + ) + stop = ( + arrow.get(data["stop"], datetime_format) + .replace(tzinfo=local_tz) + .to("utc") + if id + else None + ) # if start time of the project is not before end time # raise ValueException if not watson.is_started and start > stop: - raise ValueError( - "Task cannot end before it starts.") + raise ValueError("Task cannot end before it starts.") if start > arrow.utcnow(): raise ValueError("Start time cannot be in the future") if stop and stop > arrow.utcnow(): @@ -1355,12 +1757,12 @@ def edit(watson, confirm_new_project, confirm_new_tag, id): # the edit function normally break except (ValueError, TypeError, RuntimeError) as e: - click.echo("Error while parsing inputted values: {}".format(e), - err=True) + click.echo("Error while parsing inputted values: {}".format(e), err=True) except KeyError: click.echo( - "The edited frame must contain the project, " - "start, and stop keys.", err=True) + "The edited frame must contain the project, " "start, and stop keys.", + err=True, + ) # we reach here if exception was thrown, wait for user # to acknowledge the error before looping in while and # showing user the editor again @@ -1379,25 +1781,18 @@ def edit(watson, confirm_new_project, confirm_new_tag, id): click.echo( "Edited frame for project {project}{tags}, from {start} to {stop} " "({delta})".format( - delta=format_timedelta(stop - start) if stop else '-', - project=style('project', project), - tags=(" " if tags else "") + style('tags', tags), - start=style( - 'time', - start.to(local_tz).format(time_format) - ), - stop=style( - 'time', - stop.to(local_tz).format(time_format) if stop else '-' - ) + delta=format_timedelta(stop - start) if stop else "-", + project=style("project", project), + tags=(" " if tags else "") + style("tags", tags), + start=style("time", start.to(local_tz).format(time_format)), + stop=style("time", stop.to(local_tz).format(time_format) if stop else "-"), ) ) -@cli.command(context_settings={'ignore_unknown_options': True}) -@click.argument('id', shell_complete=get_frames) -@click.option('-f', '--force', is_flag=True, - help="Don't ask for confirmation.") +@cli.command(context_settings={"ignore_unknown_options": True}) +@click.argument("id", shell_complete=get_frames) +@click.option("-f", "--force", is_flag=True, help="Don't ask for confirmation.") @click.pass_obj @catch_watson_error def remove(watson, id, force): @@ -1412,12 +1807,12 @@ def remove(watson, id, force): click.confirm( "You are about to remove frame " "{project}{tags} from {start} to {stop}, continue?".format( - project=style('project', frame.project), - tags=(" " if frame.tags else "") + style('tags', frame.tags), - start=style('time', '{:HH:mm}'.format(frame.start)), - stop=style('time', '{:HH:mm}'.format(frame.stop)) + project=style("project", frame.project), + tags=(" " if frame.tags else "") + style("tags", frame.tags), + start=style("time", "{:HH:mm}".format(frame.start)), + stop=style("time", "{:HH:mm}".format(frame.stop)), ), - abort=True + abort=True, ) del watson.frames[id] @@ -1427,10 +1822,11 @@ def remove(watson, id, force): @cli.command() -@click.argument('key', required=False, metavar='SECTION.OPTION') -@click.argument('value', required=False) -@click.option('-e', '--edit', is_flag=True, - help="Edit the configuration file with an editor.") +@click.argument("key", required=False, metavar="SECTION.OPTION") +@click.argument("value", required=False) +@click.option( + "-e", "--edit", is_flag=True, help="Edit the configuration file with an editor." +) @click.pass_context @catch_watson_error def config(context, key, value, edit): @@ -1457,9 +1853,9 @@ def config(context, key, value, edit): with open(watson.config_file) as fp: rawconfig = fp.read() except (IOError, OSError): - rawconfig = '' + rawconfig = "" - newconfig = click.edit(text=rawconfig, extension='.ini') + newconfig = click.edit(text=rawconfig, extension=".ini") if newconfig: safe_save(watson.config_file, newconfig) @@ -1470,7 +1866,7 @@ def config(context, key, value, edit): except _watson.ConfigurationError as exc: watson.config = wconfig watson.save() - raise click.ClickException(style('error', str(exc))) + raise click.ClickException(style("error", str(exc))) return if not key: @@ -1478,11 +1874,9 @@ def config(context, key, value, edit): return try: - section, option = key.split('.') + section, option = key.split(".") except ValueError: - raise click.ClickException( - "The key must have the format 'section.option'" - ) + raise click.ClickException("The key must have the format 'section.option'") if value is None: if not wconfig.has_section(section): @@ -1534,10 +1928,14 @@ def sync(watson): @cli.command() -@click.argument('frames_with_conflict', type=click.Path(exists=True)) -@click.option('-f', '--force', 'force', is_flag=True, - help="If specified, then the merge will automatically " - "be performed.") +@click.argument("frames_with_conflict", type=click.Path(exists=True)) +@click.option( + "-f", + "--force", + "force", + is_flag=True, + help="If specified, then the merge will automatically " "be performed.", +) @click.pass_obj @catch_watson_error def merge(watson, frames_with_conflict, force): @@ -1604,12 +2002,17 @@ def merge(watson, frames_with_conflict, force): # digits of this length dig = len(str(max(len(original_frames), len(merging), len(conflicting)))) - click.echo("{:<{width}} frames will be left unchanged".format( - len(original_frames) - len(conflicting), width=dig)) - click.echo("{:<{width}} frames will be merged".format( - len(merging), width=dig)) - click.echo("{:<{width}} frames will need to be resolved".format( - len(conflicting), width=dig)) + click.echo( + "{:<{width}} frames will be left unchanged".format( + len(original_frames) - len(conflicting), width=dig + ) + ) + click.echo("{:<{width}} frames will be merged".format(len(merging), width=dig)) + click.echo( + "{:<{width}} frames will need to be resolved".format( + len(conflicting), width=dig + ) + ) # No frames to resolve or merge. if not conflicting and not merging: @@ -1622,21 +2025,29 @@ def merge(watson, frames_with_conflict, force): if conflicting: click.echo("Will resolve conflicts:") - date_format = 'YYYY-MM-DD HH:mm:ss' + date_format = "YYYY-MM-DD HH:mm:ss" for conflict_frame in conflicting: original_frame = original_frames[conflict_frame.id] # Print original frame original_frame_data = { - 'project': original_frame.project, - 'start': original_frame.start.format(date_format), - 'stop': original_frame.stop.format(date_format), - 'tags': original_frame.tags + "project": original_frame.project, + "start": original_frame.start.format(date_format), + "stop": original_frame.stop.format(date_format), + "tags": original_frame.tags, } - click.echo("frame {}:".format(style('short_id', original_frame.id))) - click.echo("{}".format('\n'.join('<' + line for line in json.dumps( - original_frame_data, indent=4, ensure_ascii=False).splitlines()))) + click.echo("frame {}:".format(style("short_id", original_frame.id))) + click.echo( + "{}".format( + "\n".join( + "<" + line + for line in json.dumps( + original_frame_data, indent=4, ensure_ascii=False + ).splitlines() + ) + ) + ) click.echo("---") # make a copy of the namedtuple @@ -1644,43 +2055,53 @@ def merge(watson, frames_with_conflict, force): # highlight conflicts if conflict_frame.project != original_frame.project: - project = '**' + str(conflict_frame.project) + '**' + project = "**" + str(conflict_frame.project) + "**" conflict_frame_copy = conflict_frame_copy._replace(project=project) if conflict_frame.start != original_frame.start: - start = '**' + str(conflict_frame.start.format(date_format)) + '**' + start = "**" + str(conflict_frame.start.format(date_format)) + "**" conflict_frame_copy = conflict_frame_copy._replace(start=start) if conflict_frame.stop != original_frame.stop: - stop = '**' + str(conflict_frame.stop.format(date_format)) + '**' + stop = "**" + str(conflict_frame.stop.format(date_format)) + "**" conflict_frame_copy = conflict_frame_copy._replace(stop=stop) for idx, tag in enumerate(conflict_frame.tags): if tag not in original_frame.tags: - conflict_frame_copy.tags[idx] = '**' + str(tag) + '**' + conflict_frame_copy.tags[idx] = "**" + str(tag) + "**" # Print conflicting frame conflict_frame_data = { - 'project': conflict_frame_copy.project, - 'start': conflict_frame_copy.start.format(date_format), - 'stop': conflict_frame_copy.stop.format(date_format), - 'tags': conflict_frame_copy.tags + "project": conflict_frame_copy.project, + "start": conflict_frame_copy.start.format(date_format), + "stop": conflict_frame_copy.stop.format(date_format), + "tags": conflict_frame_copy.tags, } - click.echo("{}".format('\n'.join('>' + line for line in json.dumps( - conflict_frame_data, indent=4, ensure_ascii=False).splitlines()))) + click.echo( + "{}".format( + "\n".join( + ">" + line + for line in json.dumps( + conflict_frame_data, indent=4, ensure_ascii=False + ).splitlines() + ) + ) + ) resp = click.prompt( "Select the frame you want to keep: left or right? (L/r)", - value_proc=options(['L', 'r'])) + value_proc=options(["L", "r"]), + ) - if resp == 'r': + if resp == "r": # replace original frame with conflicting frame original_frames[conflict_frame.id] = conflict_frame # merge in any non-conflicting frames for frame in merging: start, stop, project, id, tags, updated_at = frame.dump() - original_frames.add(project, start, stop, tags=tags, id=id, - updated_at=updated_at) + original_frames.add( + project, start, stop, tags=tags, id=id, updated_at=updated_at + ) watson.frames = original_frames watson.frames.changed = True @@ -1688,10 +2109,11 @@ def merge(watson, frames_with_conflict, force): @cli.command() -@click.argument('rename_type', required=True, metavar='TYPE', - shell_complete=get_rename_types) -@click.argument('old_name', required=True, shell_complete=get_rename_name) -@click.argument('new_name', required=True, shell_complete=get_rename_name) +@click.argument( + "rename_type", required=True, metavar="TYPE", shell_complete=get_rename_types +) +@click.argument("old_name", required=True, shell_complete=get_rename_name) +@click.argument("new_name", required=True, shell_complete=get_rename_name) @click.pass_obj @catch_watson_error def rename(watson, rename_type, old_name, new_name): @@ -1707,21 +2129,25 @@ def rename(watson, rename_type, old_name, new_name): Renamed tag "company-meeting" to "meeting" """ - if rename_type == 'tag': + if rename_type == "tag": watson.rename_tag(old_name, new_name) - click.echo('Renamed tag "{}" to "{}"'.format( - style('tag', old_name), - style('tag', new_name) - )) - elif rename_type == 'project': + click.echo( + 'Renamed tag "{}" to "{}"'.format( + style("tag", old_name), style("tag", new_name) + ) + ) + elif rename_type == "project": watson.rename_project(old_name, new_name) - click.echo('Renamed project "{}" to "{}"'.format( - style('project', old_name), - style('project', new_name) - )) + click.echo( + 'Renamed project "{}" to "{}"'.format( + style("project", old_name), style("project", new_name) + ) + ) else: - raise click.ClickException(style( - 'error', - 'You have to call rename with type "project" or "tag"; ' - 'you supplied "%s"' % rename_type - )) + raise click.ClickException( + style( + "error", + 'You have to call rename with type "project" or "tag"; ' + 'you supplied "%s"' % rename_type, + ) + ) diff --git a/watson/config.py b/watson/config.py index bd3d61f8..221d033c 100644 --- a/watson/config.py +++ b/watson/config.py @@ -3,7 +3,7 @@ import shlex from configparser import RawConfigParser -__all__ = ('ConfigParser',) +__all__ = ("ConfigParser",) class ConfigParser(RawConfigParser): @@ -16,8 +16,11 @@ def get(self, section, option, default=None, **kwargs): If option is not set, return default instead (defaults to None). """ - return (RawConfigParser.get(self, section, option, **kwargs) - if self.has_option(section, option) else default) + return ( + RawConfigParser.get(self, section, option, **kwargs) + if self.has_option(section, option) + else default + ) def getint(self, section, option, default=None): """ @@ -55,7 +58,7 @@ def getboolean(self, section, option, default=False): """ val = self.get(section, option) - return val.lower() in ('1', 'on', 'true', 'yes') if val else default + return val.lower() in ("1", "on", "true", "yes") if val else default def getlist(self, section, option, default=None): """ @@ -88,9 +91,8 @@ def getlist(self, section, option, default=None): value = self.get(section, option) - if '\n' in value: - return [item.strip() - for item in value.splitlines() if item.strip()] + if "\n" in value: + return [item.strip() for item in value.splitlines() if item.strip()] else: return shlex.split(value) diff --git a/watson/frames.py b/watson/frames.py index 0e511578..600182c2 100644 --- a/watson/frames.py +++ b/watson/frames.py @@ -4,11 +4,19 @@ from collections import namedtuple -HEADERS = ('start', 'stop', 'project', 'id', 'tags', 'updated_at') +HEADERS = ("start", "stop", "project", "id", "tags", "updated_at") -class Frame(namedtuple('Frame', HEADERS)): - def __new__(cls, start, stop, project, id, tags=None, updated_at=None,): +class Frame(namedtuple("Frame", HEADERS)): + def __new__( + cls, + start, + stop, + project, + id, + tags=None, + updated_at=None, + ): try: if not isinstance(start, arrow.Arrow): start = arrow.get(start) @@ -22,12 +30,13 @@ def __new__(cls, start, stop, project, id, tags=None, updated_at=None,): updated_at = arrow.get(updated_at) except (ValueError, TypeError) as e: from .watson import WatsonError + raise WatsonError("Error converting date: {}".format(e)) - start = start.to('local') + start = start.to("local") if stop: - stop = stop.to('local') + stop = stop.to("local") if tags is None: tags = [] @@ -37,15 +46,15 @@ def __new__(cls, start, stop, project, id, tags=None, updated_at=None,): ) def dump(self): - start = self.start.to('utc').int_timestamp - stop = self.stop.to('utc').int_timestamp if self.stop else None + start = self.start.to("utc").int_timestamp + stop = self.stop.to("utc").int_timestamp if self.stop else None updated_at = self.updated_at.int_timestamp return (start, stop, self.project, self.id, self.tags, updated_at) @property def day(self): - return self.start.floor('day') + return self.start.floor("day") def __lt__(self, other): return self.start < other.start @@ -61,7 +70,7 @@ def __gte__(self, other): class Span(object): - def __init__(self, start, stop, timeframe='day'): + def __init__(self, start, stop, timeframe="day"): self.timeframe = timeframe self.start = start.floor(self.timeframe) self.stop = stop.ceil(self.timeframe) @@ -121,9 +130,7 @@ def __delitem__(self, key): def _get_index_by_id(self, id): try: - return next( - i for i, v in enumerate(self['id']) if v.startswith(id) - ) + return next(i for i, v in enumerate(self["id"]) if v.startswith(id)) except StopIteration: raise KeyError("Frame with id {} not found.".format(id)) @@ -138,12 +145,10 @@ def add(self, *args, **kwargs): self._rows.append(frame) return frame - def new_frame(self, project, start, stop, tags=None, id=None, - updated_at=None): + def new_frame(self, project, start, stop, tags=None, id=None, updated_at=None): if not id: id = uuid.uuid4().hex - return Frame(start, stop, project, id, tags=tags, - updated_at=updated_at) + return Frame(start, stop, project, id, tags=tags, updated_at=updated_at) def dump(self): return tuple(frame.dump() for frame in self._rows) @@ -161,14 +166,14 @@ def filter( for frame in self._rows: if projects is not None and frame.project not in projects: continue - if ignore_projects is not None and\ - frame.project in ignore_projects: + if ignore_projects is not None and frame.project in ignore_projects: continue if tags is not None and not any(tag in frame.tags for tag in tags): continue - if ignore_tags is not None and\ - any(tag in frame.tags for tag in ignore_tags): + if ignore_tags is not None and any( + tag in frame.tags for tag in ignore_tags + ): continue if span is None: diff --git a/watson/fullmoon.py b/watson/fullmoon.py index 5abd5535..5b1c5953 100644 --- a/watson/fullmoon.py +++ b/watson/fullmoon.py @@ -5,213 +5,1243 @@ # data source http://home.hiwaay.net/~krcool/Astro/moon/fullmoon.htm fullmoons = [ - 948429720, 950977680, 953527560, 956079780, 958635300, 961194480, - 963755820, 966316440, 968873880, 971427240, 973977360, 976525500, - 979071960, 981616380, 984158640, 986700240, 989243700, 991791660, - 994345560, 996904620, 999467040, 1002030600, 1004593320, 1007153400, - 1009708920, 1012258320, 1014801480, 1017340020, 1019876580, 1022414040, - 1024955100, 1027501680, 1030055400, 1032616800, 1035184860, 1037756100, - 1040325060, 1042886940, 1045439520, 1047983760, 1050521880, 1053056280, - 1055589480, 1058124180, 1060663740, 1063211820, 1065770940, 1068340500, - 1070915880, 1073490060, 1076057280, 1078614960, 1081163040, 1083702900, - 1086236460, 1088766600, 1091297160, 1093832580, 1096377000, 1098932940, - 1101499740, 1104073680, 1106649180, 1109220900, 1111784400, 1114337280, - 1116879600, 1119413700, 1121943660, 1124474040, 1127008860, 1129551300, - 1132102740, 1134663420, 1137232140, 1139805900, 1142379420, 1144946520, - 1147503180, 1150049040, 1152586980, 1155120840, 1157654520, 1160190780, - 1162731540, 1165278360, 1167832740, 1170395220, 1172963880, 1175534160, - 1178100660, 1180659900, 1183211400, 1185756480, 1188297300, 1190835960, - 1193374380, 1195914660, 1198459020, 1201008960, 1203564720, 1206124860, - 1208687220, 1211249580, 1213810320, 1216368000, 1218921480, 1221470100, - 1224014640, 1226557140, 1229099940, 1231644480, 1234191060, 1236739200, - 1239289080, 1241841780, 1244398380, 1246958580, 1249520220, 1252080300, - 1254636660, 1257189300, 1259739180, 1262286900, 1264832340, 1267375200, - 1269916080, 1272457260, 1275001740, 1277551920, 1280108280, 1282669560, - 1285233540, 1287797880, 1290360480, 1292919300, 1295472180, 1298018280, - 1300558320, 1303094760, 1305630600, 1308168900, 1310712060, 1313261940, - 1315819680, 1318385280, 1320956280, 1323527820, 1326094260, 1328651760, - 1331199720, 1333740060, 1336275420, 1338808380, 1341341640, 1343878140, - 1346421600, 1348975260, 1351540320, 1354114080, 1356690120, 1359261600, - 1361824080, 1364376540, 1366919940, 1369456020, 1371987240, 1374517080, - 1377049620, 1379589300, 1382139600, 1384701480, 1387272600, 1389848040, - 1392422100, 1394989800, 1397547840, 1400095020, 1402632780, 1405164360, - 1407694260, 1410226800, 1412765520, 1415312640, 1417868940, 1420433700, - 1423005060, 1425578820, 1428149220, 1430710980, 1433262000, 1435803660, - 1438339440, 1440873420, 1443408720, 1445947560, 1448491500, 1451041980, - 1453600080, 1456165320, 1458734520, 1461302700, 1463865360, 1466420640, - 1468969140, 1471512540, 1474052820, 1476591900, 1479131580, 1481674020, - 1484220960, 1486773240, 1489330500, 1491890940, 1494452640, 1497013860, - 1499573340, 1502129580, 1504681440, 1507228860, 1509773040, 1512316140, - 1514859960, 1517405280, 1519951920, 1522499880, 1525050000, 1527603660, - 1530161700, 1532722920, 1535284680, 1537844040, 1540399560, 1542951660, - 1545501000, 1548047820, 1550591640, 1553132640, 1555672440, 1558213980, - 1560760320, 1563313200, 1565872260, 1568435640, 1571000940, 1573565760, - 1576127640, 1578684180, 1581233700, 1583776140, 1586313420, 1588848420, - 1591384500, 1593924360, 1596470400, 1599024180, 1601586360, 1604155860, - 1606728660, 1609298940, 1611861420, 1614413940, 1616957400, 1619494440, - 1622027760, 1624560120, 1627094340, 1629633780, 1632182160, 1634741880, - 1637312340, 1639888620, 1642463400, 1645030680, 1647587940, 1650135420, - 1652674560, 1655207580, 1657737540, 1660268160, 1662803940, 1665348960, - 1667905440, 1670472600, 1673046600, 1675621800, 1678192920, 1680755760, - 1683308160, 1685850180, 1688384340, 1690914720, 1693445760, 1695981480, - 1698524700, 1701076680, 1703637300, 1706205360, 1708777920, 1711350120, - 1713916260, 1716472500, 1719018540, 1721557080, 1724091960, 1726626900, - 1729164480, 1731706200, 1734253380, 1736807340, 1739368500, 1741935360, - 1744503840, 1747069080, 1749627960, 1752179880, 1754726160, 1757268540, - 1759808880, 1762348860, 1764890160, 1767434640, 1769983860, 1772538000, - 1775096040, 1777656300, 1780217220, 1782777480, 1785335820, 1787890800, - 1790441400, 1792987980, 1795532100, 1798075800, 1800620340, 1803165960, - 1805712420, 1808260200, 1810810860, 1813365960, 1815925620, 1818487860, - 1821049560, 1823608140, 1826162880, 1828714260, 1831262700, 1833807960, - 1836349740, 1838889000, 1841428260, 1843971060, 1846519980, 1849075920, - 1851637800, 1854203220, 1856769540, 1859334120, 1861894200, 1864447500, - 1866993180, 1869532140, 1872067140, 1874601600, 1877138700, 1879681080, - 1882230780, 1884789060, 1887355800, 1889928240, 1892501220, 1895068500, - 1897626120, 1900173540, 1902712920, 1905247260, 1907779380, 1910312040, - 1912848360, 1915392000, 1917946140, 1920511980, 1923086520, 1925663220, - 1928234880, 1930797060, 1933348980, 1935891660, 1938427200, 1940958180, - 1943488020, 1946020920, 1948561200, 1951112100, 1953674460, 1956245700, - 1958820840, 1961394300, 1963961280, 1966518660, 1969065480, 1971603240, - 1974135180, 1976665740, 1979199120, 1981738800, 1984286640, 1986843060, - 1989407340, 1991977560, 1994549940, 1997119140, 1999680240, 2002231260, - 2004773400, 2007310140, 2009845320, 2012382000, 2014922040, 2017466640, - 2020016940, 2022574020, 2025137520, 2027704800, 2030271420, 2032833300, - 2035388760, 2037938160, 2040483060, 2043025080, 2045565840, 2048106780, - 2050649760, 2053196280, 2055747300, 2058302640, 2060860920, 2063420820, - 2065981140, 2068540740, 2071098120, 2073651900, 2076201420, 2078747400, - 2081291700, 2083835880, 2086380600, 2088925860, 2091471840, 2094019860, - 2096571780, 2099128860, 2101690260, 2104253220, 2106814560, 2109372300, - 2111926200, 2114476680, 2117023560, 2119566600, 2122106100, 2124644160, - 2127184020, 2129728920, 2132281020, 2134840260, 2137404780, 2139971880, - 2142538620, 2145102000, 2147659320, 2150208660, 2152750260, 2155286280, - 2157819960, 2160354780, 2162893800, 2165439480, 2167993500, 2170556580, - 2173127340, 2175701520, 2178272820, 2180835660, 2183387820, 2185930500, - 2188466520, 2190999000, 2193530700, 2196064680, 2198604240, 2201153040, - 2203713480, 2206284720, 2208861540, 2211436560, 2214003660, 2216560380, - 2219107200, 2221645740, 2224178460, 2226708420, 2229239460, 2231775840, - 2234321460, 2236878480, 2239445880, 2242019580, 2244594120, 2247164460, - 2249726580, 2252278440, 2254820400, 2257354920, 2259885900, 2262417840, - 2264954580, 2267498700, 2270051040, 2272611300, 2275178340, 2277749460, - 2280320280, 2282885460, 2285441400, 2287987860, 2290527240, 2293063320, - 2295599640, 2298138540, 2300681220, 2303228640, 2305781880, 2308341540, - 2310906420, 2313473040, 2316037140, 2318595720, 2321148360, 2323695900, - 2326240080, 2328782220, 2331323640, 2333865840, 2336410380, 2338958580, - 2341510980, 2344066860, 2346625080, 2349184680, 2351744640, 2354303760, - 2356860360, 2359413120, 2361961740, 2364507360, 2367051720, 2369596020, - 2372140500, 2374685160, 2377230900, 2379779700, 2382333480, 2384892780, - 2387455800, 2390019240, 2392579980, 2395136700, 2397689460, 2400238380, - 2402783220, 2405323800, 2407861440, 2410399080, 2412940320, 2415488220, - 2418043920, 2420606520, 2423173440, 2425741560, 2428307820, 2430868980, - 2433422520, 2435967600, 2438505480, 2441039220, 2443572420, 2446108560, - 2448650400, 2451200160, 2453759040, 2456326800, 2458900620, 2461474680, - 2464042560, 2466600000, 2469146820, 2471685300, 2474218740, 2476750200, - 2479282560, 2481818940, 2484362940, 2486917680, 2489484120, 2492059260, - 2494636200, 2497207740, 2499769500, 2502320820, 2504862960, 2507398080, - 2509929060, 2512459260, 2514992760, 2517533700, 2520085200, 2522647800, - 2525218860, 2527793340, 2530365900, 2532932040, 2535488880, 2538035580, - 2540573520, 2543106120, 2545637520, 2548171980, 2550712620, 2553261060, - 2555817420, 2558380920, 2560949700, 2563520520, 2566088460, 2568648960, - 2571200160, 2573743080, 2576281020, 2578817580, 2581355700, 2583896880, - 2586442020, 2588991960, 2591547780, 2594109420, 2596674660, 2599239660, - 2601800940, 2604356700, 2606907300, 2609453820, 2611997820, 2614540260, - 2617082340, 2619625680, 2622171540, 2624721060, 2627274240, 2629830420, - 2632388640, 2634948180, 2637508140, 2640066900, 2642622720, 2645174400, - 2647722180, 2650267500, 2652811800, 2655355680, 2657899380, 2660443440, - 2662989480, 2665539840, 2668096140, 2670657840, 2673222180, 2675785560, - 2678345400, 2680900980, 2683452240, 2685999000, 2688541080, 2691079200, - 2693615640, 2696153880, 2698697640, 2701249140, 2703808680, 2706374400, - 2708943180, 2711511720, 2714076420, 2716634220, 2719183320, 2721724020, - 2724258780, 2726791260, 2729325000, 2731863420, 2734408920, 2736963360, - 2739527280, 2742099240, 2744674500, 2747246520, 2749809480, 2752361220, - 2754903060, 2757438300, 2759970060, 2762501400, 2765035380, 2767575240, - 2770124580, 2772685560, 2775257340, 2777834400, 2780409360, 2782976100, - 2785532280, 2788078500, 2790616620, 2793149280, 2795679540, 2798211120, - 2800748220, 2803294560, 2805851940, 2808419280, 2810992380, 2813566080, - 2816135400, 2818696740, 2821248360, 2823790440, 2826325500, 2828857320, - 2831390340, 2833928160, 2836473060, 2839025640, 2841585360, 2844151080, - 2846720580, 2849289840, 2851854120, 2854409940, 2856957000, 2859497520, - 2862035040, 2864572920, 2867113140, 2869656600, 2872203960, 2874756300, - 2877314220, 2879876940, 2882441700, 2885004720, 2887563300, 2890116780, - 2892665940, 2895212040, 2897756040, 2900298900, 2902841700, 2905385940, - 2907933000, 2910483480, 2913037200, 2915593500, 2918151960, 2920711740, - 2923271880, 2925830340, 2928385200, 2930935800, 2933482800, 2936027640, - 2938571460, 2941114680, 2943657480, 2946201240, 2948748300, 2951301000, - 2953860120, 2956424160, 2958989400, 2961552240, 2964110880, 2966664840, - 2969213940, 2971758120, 2974297440, 2976833520, 2979369540, 2981909520, - 2984456580, 2987012280, 2989575660, 2992144080, 2994714000, 2997281760, - 2999843700, 3002397240, 3004941540, 3007478340, 3010010880, 3012543060, - 3015078480, 3017620080, 3020170020, 3022729620, 3025298400, 3027873240, - 3030448140, 3033016260, 3035573460, 3038119560, 3040657260, 3043189980, - 3045721020, 3048253260, 3050789940, 3053334420, 3055889700, 3058456740, - 3061032180, 3063609060, 3066180240, 3068741460, 3071292120, 3073833840, - 3076368840, 3078900000, 3081430740, 3083964960, 3086506680, 3089058780, - 3091621440, 3094192020, 3096765600, 3099337080, 3101902260, 3104458560, - 3107005260, 3109543740, 3112077180, 3114609660, 3117145320, 3119686980, - 3122235840, 3124791960, 3127354320, 3129921480, 3132490500, 3135057180, - 3137617260, 3140168880, 3142712820, 3145252140, 3147790320, 3150329880, - 3152872080, 3155417520, 3157966920, 3160521240, 3163080780, 3165643980, - 3168207540, 3170768340, 3173324700, 3175876560, 3178425000, 3180970860, - 3183514980, 3186058080, 3188601480, 3191146560, 3193694400, 3196245480, - 3198799560, 3201356280, 3203915280, 3206475720, 3209036040, 3211593960, - 3214147740, 3216697200, 3219243360, 3221787480, 3224330340, 3226872420, - 3229414560, 3231958860, 3234507840, 3237063540, 3239625720, 3242191560, - 3244756980, 3247318860, 3249875880, 3252427680, 3254974140, 3257515140, - 3260051880, 3262586760, 3265123680, 3267666480, 3270217680, 3272777640, - 3275344560, 3277915020, 3280485060, 3283050780, 3285608820, 3288157440, - 3290697180, 3293230860, 3295762260, 3298295280, 3300833280, 3303378900, - 3305933820, 3308498580, 3311071500, 3313647660, 3316220040, 3318782880, - 3321334020, 3323875200, 3326409660, 3328940940, 3331472220, 3334006380, - 3336546780, 3339096660, 3341658180, 3344230200, 3346807320, 3349381860, - 3351947940, 3354503520, 3357049260, 3359587200, 3362120040, 3364650840, - 3367183200, 3369721140, 3372268200, 3374825880, 3377392800, 3379965000, - 3382537500, 3385105680, 3387666360, 3390217740, 3392760240, 3395296080, - 3397829040, 3400363320, 3402902280, 3405447900, 3408000420, 3410559360, - 3413123520, 3415691160, 3418258860, 3420822300, 3423378240, 3425926140, - 3428467980, 3431007120, 3433546620, 3436088100, 3438632220, 3441179340, - 3443730480, 3446286480, 3448847100, 3451410060, 3453972060, 3456530760, - 3459085320, 3461636280, 3464184360, 3466730220, 3469274340, 3471817620, - 3474361440, 3476907120, 3479455620, 3482007180, 3484561680, 3487119060, - 3489678960, 3492240240, 3494800620, 3497357700, 3499910220, 3502458420, - 3505003440, 3507546600, 3510088380, 3512629440, 3515171340, 3517716900, - 3520268700, 3522827880, 3525392940, 3527959980, 3530524920, 3533085240, - 3535640040, 3538189200, 3540732540, 3543270600, 3545805180, 3548339880, - 3550878780, 3553425300, 3555981120, 3558545400, 3561115260, 3563686740, - 3566255700, 3568818180, 3571371480, 3573915060, 3576450780, 3578982300, - 3581513640, 3584048580, 3586590120, 3589140480, 3591700740, 3594270360, - 3596846040, 3599421420, 3601989540, 3604546320, 3607091760, 3609628800, - 3612160980, 3614691840, 3617224320, 3619761480, 3622306500, 3624862380, - 3627429720, 3630005100, 3632581620, 3635152140, 3637712640, 3640262820, - 3642804300, 3645339420, 3647871120, 3650402580, 3652937760, 3655480260, - 3658032780, 3660595320, 3663165060, 3665737440, 3668307660, 3670871880, - 3673427820, 3675974760, 3678513960, 3681048420, 3683582220, 3686119140, - 3688661700, 3691210920, 3693766440, 3696327480, 3698892720, 3701460000, - 3704025480, 3706585260, 3709137420, 3711682560, 3714223500, 3716763420, - 3719304480, 3721847640, 3724393200, 3726941760, 3729494400, 3732051780, - 3734612880, 3737175060, 3739735500, 3742292580, 3744846060, 3747396480, - 3749944380, 3752490060, 3755034000, 3757577280, 3760121280, 3762667380, - 3765216300, 3767768400, 3770323800, 3772882380, 3775443540, 3778005480, - 3780565560, 3783121500, 3785672400, 3788219160, 3790762920, 3793304640, - 3795845100, 3798385440, 3800928060, 3803475960, 3806031240, 3808594020, - 3811161360, 3813728820, 3816292560, 3818850780, 3821402940, 3823948860, - 3826488840, 3829024140, 3831557640, 3834093420, 3836635500, 3839186520, - 3841747140, 3844315200, 3846887160, 3849458520, 3852025020, 3854583120, - 3857131080, 3859669920, 3862202580, 3864733140, 3867265620, 3869803500, - 3872349360, 3874904880, 3877470420, 3880044120, 3882620760, 3885193260, - 3887755740, 3890306340, 3892846800, 3895380780, 3897911820, 3900443220, - 3902977800, 3905518800, 3908069280, 3910631220, 3913203240, 3915779940, - 3918353820, 3920919240, 3923474220, 3926019660, 3928557660, 3931090920, - 3933622440, 3936155700, 3938694600, 3941242260, 3943799940, 3946366260, - 3948937320, 3951508380, 3954075420, 3956635440, 3959186880, 3961729980, - 3964266900, 3966801120, 3969336720, 3971876880, 3974423040, 3976975380, - 3979533180, 3982095600, 3984661260, 3987227400, 3989790120, 3992346300, - 3994895280, 3997438680, 3999979560, 4002520740, 4005063420, 4007607960, - 4010154600, 4012704300, 4015258320, 4017816720, 4020377940, 4022939220, - 4025498220, 4028054100, 4030606920, 4033157100, 4035704760, 4038250020, - 4040793540, 4043336640, 4045880820, 4048427280, 4050976800, 4053529680, - 4056086220, 4058646360, 4061208960, 4063771380, 4066330620, 4068884940, - 4071434040, 4073979120, 4076521380, 4079061660, 4081600980, 4084141260, - 4086685440, 4089236520, 4091795880, 4094362140, 4096931040, 4099497900, - 4102059660 + 948429720, + 950977680, + 953527560, + 956079780, + 958635300, + 961194480, + 963755820, + 966316440, + 968873880, + 971427240, + 973977360, + 976525500, + 979071960, + 981616380, + 984158640, + 986700240, + 989243700, + 991791660, + 994345560, + 996904620, + 999467040, + 1002030600, + 1004593320, + 1007153400, + 1009708920, + 1012258320, + 1014801480, + 1017340020, + 1019876580, + 1022414040, + 1024955100, + 1027501680, + 1030055400, + 1032616800, + 1035184860, + 1037756100, + 1040325060, + 1042886940, + 1045439520, + 1047983760, + 1050521880, + 1053056280, + 1055589480, + 1058124180, + 1060663740, + 1063211820, + 1065770940, + 1068340500, + 1070915880, + 1073490060, + 1076057280, + 1078614960, + 1081163040, + 1083702900, + 1086236460, + 1088766600, + 1091297160, + 1093832580, + 1096377000, + 1098932940, + 1101499740, + 1104073680, + 1106649180, + 1109220900, + 1111784400, + 1114337280, + 1116879600, + 1119413700, + 1121943660, + 1124474040, + 1127008860, + 1129551300, + 1132102740, + 1134663420, + 1137232140, + 1139805900, + 1142379420, + 1144946520, + 1147503180, + 1150049040, + 1152586980, + 1155120840, + 1157654520, + 1160190780, + 1162731540, + 1165278360, + 1167832740, + 1170395220, + 1172963880, + 1175534160, + 1178100660, + 1180659900, + 1183211400, + 1185756480, + 1188297300, + 1190835960, + 1193374380, + 1195914660, + 1198459020, + 1201008960, + 1203564720, + 1206124860, + 1208687220, + 1211249580, + 1213810320, + 1216368000, + 1218921480, + 1221470100, + 1224014640, + 1226557140, + 1229099940, + 1231644480, + 1234191060, + 1236739200, + 1239289080, + 1241841780, + 1244398380, + 1246958580, + 1249520220, + 1252080300, + 1254636660, + 1257189300, + 1259739180, + 1262286900, + 1264832340, + 1267375200, + 1269916080, + 1272457260, + 1275001740, + 1277551920, + 1280108280, + 1282669560, + 1285233540, + 1287797880, + 1290360480, + 1292919300, + 1295472180, + 1298018280, + 1300558320, + 1303094760, + 1305630600, + 1308168900, + 1310712060, + 1313261940, + 1315819680, + 1318385280, + 1320956280, + 1323527820, + 1326094260, + 1328651760, + 1331199720, + 1333740060, + 1336275420, + 1338808380, + 1341341640, + 1343878140, + 1346421600, + 1348975260, + 1351540320, + 1354114080, + 1356690120, + 1359261600, + 1361824080, + 1364376540, + 1366919940, + 1369456020, + 1371987240, + 1374517080, + 1377049620, + 1379589300, + 1382139600, + 1384701480, + 1387272600, + 1389848040, + 1392422100, + 1394989800, + 1397547840, + 1400095020, + 1402632780, + 1405164360, + 1407694260, + 1410226800, + 1412765520, + 1415312640, + 1417868940, + 1420433700, + 1423005060, + 1425578820, + 1428149220, + 1430710980, + 1433262000, + 1435803660, + 1438339440, + 1440873420, + 1443408720, + 1445947560, + 1448491500, + 1451041980, + 1453600080, + 1456165320, + 1458734520, + 1461302700, + 1463865360, + 1466420640, + 1468969140, + 1471512540, + 1474052820, + 1476591900, + 1479131580, + 1481674020, + 1484220960, + 1486773240, + 1489330500, + 1491890940, + 1494452640, + 1497013860, + 1499573340, + 1502129580, + 1504681440, + 1507228860, + 1509773040, + 1512316140, + 1514859960, + 1517405280, + 1519951920, + 1522499880, + 1525050000, + 1527603660, + 1530161700, + 1532722920, + 1535284680, + 1537844040, + 1540399560, + 1542951660, + 1545501000, + 1548047820, + 1550591640, + 1553132640, + 1555672440, + 1558213980, + 1560760320, + 1563313200, + 1565872260, + 1568435640, + 1571000940, + 1573565760, + 1576127640, + 1578684180, + 1581233700, + 1583776140, + 1586313420, + 1588848420, + 1591384500, + 1593924360, + 1596470400, + 1599024180, + 1601586360, + 1604155860, + 1606728660, + 1609298940, + 1611861420, + 1614413940, + 1616957400, + 1619494440, + 1622027760, + 1624560120, + 1627094340, + 1629633780, + 1632182160, + 1634741880, + 1637312340, + 1639888620, + 1642463400, + 1645030680, + 1647587940, + 1650135420, + 1652674560, + 1655207580, + 1657737540, + 1660268160, + 1662803940, + 1665348960, + 1667905440, + 1670472600, + 1673046600, + 1675621800, + 1678192920, + 1680755760, + 1683308160, + 1685850180, + 1688384340, + 1690914720, + 1693445760, + 1695981480, + 1698524700, + 1701076680, + 1703637300, + 1706205360, + 1708777920, + 1711350120, + 1713916260, + 1716472500, + 1719018540, + 1721557080, + 1724091960, + 1726626900, + 1729164480, + 1731706200, + 1734253380, + 1736807340, + 1739368500, + 1741935360, + 1744503840, + 1747069080, + 1749627960, + 1752179880, + 1754726160, + 1757268540, + 1759808880, + 1762348860, + 1764890160, + 1767434640, + 1769983860, + 1772538000, + 1775096040, + 1777656300, + 1780217220, + 1782777480, + 1785335820, + 1787890800, + 1790441400, + 1792987980, + 1795532100, + 1798075800, + 1800620340, + 1803165960, + 1805712420, + 1808260200, + 1810810860, + 1813365960, + 1815925620, + 1818487860, + 1821049560, + 1823608140, + 1826162880, + 1828714260, + 1831262700, + 1833807960, + 1836349740, + 1838889000, + 1841428260, + 1843971060, + 1846519980, + 1849075920, + 1851637800, + 1854203220, + 1856769540, + 1859334120, + 1861894200, + 1864447500, + 1866993180, + 1869532140, + 1872067140, + 1874601600, + 1877138700, + 1879681080, + 1882230780, + 1884789060, + 1887355800, + 1889928240, + 1892501220, + 1895068500, + 1897626120, + 1900173540, + 1902712920, + 1905247260, + 1907779380, + 1910312040, + 1912848360, + 1915392000, + 1917946140, + 1920511980, + 1923086520, + 1925663220, + 1928234880, + 1930797060, + 1933348980, + 1935891660, + 1938427200, + 1940958180, + 1943488020, + 1946020920, + 1948561200, + 1951112100, + 1953674460, + 1956245700, + 1958820840, + 1961394300, + 1963961280, + 1966518660, + 1969065480, + 1971603240, + 1974135180, + 1976665740, + 1979199120, + 1981738800, + 1984286640, + 1986843060, + 1989407340, + 1991977560, + 1994549940, + 1997119140, + 1999680240, + 2002231260, + 2004773400, + 2007310140, + 2009845320, + 2012382000, + 2014922040, + 2017466640, + 2020016940, + 2022574020, + 2025137520, + 2027704800, + 2030271420, + 2032833300, + 2035388760, + 2037938160, + 2040483060, + 2043025080, + 2045565840, + 2048106780, + 2050649760, + 2053196280, + 2055747300, + 2058302640, + 2060860920, + 2063420820, + 2065981140, + 2068540740, + 2071098120, + 2073651900, + 2076201420, + 2078747400, + 2081291700, + 2083835880, + 2086380600, + 2088925860, + 2091471840, + 2094019860, + 2096571780, + 2099128860, + 2101690260, + 2104253220, + 2106814560, + 2109372300, + 2111926200, + 2114476680, + 2117023560, + 2119566600, + 2122106100, + 2124644160, + 2127184020, + 2129728920, + 2132281020, + 2134840260, + 2137404780, + 2139971880, + 2142538620, + 2145102000, + 2147659320, + 2150208660, + 2152750260, + 2155286280, + 2157819960, + 2160354780, + 2162893800, + 2165439480, + 2167993500, + 2170556580, + 2173127340, + 2175701520, + 2178272820, + 2180835660, + 2183387820, + 2185930500, + 2188466520, + 2190999000, + 2193530700, + 2196064680, + 2198604240, + 2201153040, + 2203713480, + 2206284720, + 2208861540, + 2211436560, + 2214003660, + 2216560380, + 2219107200, + 2221645740, + 2224178460, + 2226708420, + 2229239460, + 2231775840, + 2234321460, + 2236878480, + 2239445880, + 2242019580, + 2244594120, + 2247164460, + 2249726580, + 2252278440, + 2254820400, + 2257354920, + 2259885900, + 2262417840, + 2264954580, + 2267498700, + 2270051040, + 2272611300, + 2275178340, + 2277749460, + 2280320280, + 2282885460, + 2285441400, + 2287987860, + 2290527240, + 2293063320, + 2295599640, + 2298138540, + 2300681220, + 2303228640, + 2305781880, + 2308341540, + 2310906420, + 2313473040, + 2316037140, + 2318595720, + 2321148360, + 2323695900, + 2326240080, + 2328782220, + 2331323640, + 2333865840, + 2336410380, + 2338958580, + 2341510980, + 2344066860, + 2346625080, + 2349184680, + 2351744640, + 2354303760, + 2356860360, + 2359413120, + 2361961740, + 2364507360, + 2367051720, + 2369596020, + 2372140500, + 2374685160, + 2377230900, + 2379779700, + 2382333480, + 2384892780, + 2387455800, + 2390019240, + 2392579980, + 2395136700, + 2397689460, + 2400238380, + 2402783220, + 2405323800, + 2407861440, + 2410399080, + 2412940320, + 2415488220, + 2418043920, + 2420606520, + 2423173440, + 2425741560, + 2428307820, + 2430868980, + 2433422520, + 2435967600, + 2438505480, + 2441039220, + 2443572420, + 2446108560, + 2448650400, + 2451200160, + 2453759040, + 2456326800, + 2458900620, + 2461474680, + 2464042560, + 2466600000, + 2469146820, + 2471685300, + 2474218740, + 2476750200, + 2479282560, + 2481818940, + 2484362940, + 2486917680, + 2489484120, + 2492059260, + 2494636200, + 2497207740, + 2499769500, + 2502320820, + 2504862960, + 2507398080, + 2509929060, + 2512459260, + 2514992760, + 2517533700, + 2520085200, + 2522647800, + 2525218860, + 2527793340, + 2530365900, + 2532932040, + 2535488880, + 2538035580, + 2540573520, + 2543106120, + 2545637520, + 2548171980, + 2550712620, + 2553261060, + 2555817420, + 2558380920, + 2560949700, + 2563520520, + 2566088460, + 2568648960, + 2571200160, + 2573743080, + 2576281020, + 2578817580, + 2581355700, + 2583896880, + 2586442020, + 2588991960, + 2591547780, + 2594109420, + 2596674660, + 2599239660, + 2601800940, + 2604356700, + 2606907300, + 2609453820, + 2611997820, + 2614540260, + 2617082340, + 2619625680, + 2622171540, + 2624721060, + 2627274240, + 2629830420, + 2632388640, + 2634948180, + 2637508140, + 2640066900, + 2642622720, + 2645174400, + 2647722180, + 2650267500, + 2652811800, + 2655355680, + 2657899380, + 2660443440, + 2662989480, + 2665539840, + 2668096140, + 2670657840, + 2673222180, + 2675785560, + 2678345400, + 2680900980, + 2683452240, + 2685999000, + 2688541080, + 2691079200, + 2693615640, + 2696153880, + 2698697640, + 2701249140, + 2703808680, + 2706374400, + 2708943180, + 2711511720, + 2714076420, + 2716634220, + 2719183320, + 2721724020, + 2724258780, + 2726791260, + 2729325000, + 2731863420, + 2734408920, + 2736963360, + 2739527280, + 2742099240, + 2744674500, + 2747246520, + 2749809480, + 2752361220, + 2754903060, + 2757438300, + 2759970060, + 2762501400, + 2765035380, + 2767575240, + 2770124580, + 2772685560, + 2775257340, + 2777834400, + 2780409360, + 2782976100, + 2785532280, + 2788078500, + 2790616620, + 2793149280, + 2795679540, + 2798211120, + 2800748220, + 2803294560, + 2805851940, + 2808419280, + 2810992380, + 2813566080, + 2816135400, + 2818696740, + 2821248360, + 2823790440, + 2826325500, + 2828857320, + 2831390340, + 2833928160, + 2836473060, + 2839025640, + 2841585360, + 2844151080, + 2846720580, + 2849289840, + 2851854120, + 2854409940, + 2856957000, + 2859497520, + 2862035040, + 2864572920, + 2867113140, + 2869656600, + 2872203960, + 2874756300, + 2877314220, + 2879876940, + 2882441700, + 2885004720, + 2887563300, + 2890116780, + 2892665940, + 2895212040, + 2897756040, + 2900298900, + 2902841700, + 2905385940, + 2907933000, + 2910483480, + 2913037200, + 2915593500, + 2918151960, + 2920711740, + 2923271880, + 2925830340, + 2928385200, + 2930935800, + 2933482800, + 2936027640, + 2938571460, + 2941114680, + 2943657480, + 2946201240, + 2948748300, + 2951301000, + 2953860120, + 2956424160, + 2958989400, + 2961552240, + 2964110880, + 2966664840, + 2969213940, + 2971758120, + 2974297440, + 2976833520, + 2979369540, + 2981909520, + 2984456580, + 2987012280, + 2989575660, + 2992144080, + 2994714000, + 2997281760, + 2999843700, + 3002397240, + 3004941540, + 3007478340, + 3010010880, + 3012543060, + 3015078480, + 3017620080, + 3020170020, + 3022729620, + 3025298400, + 3027873240, + 3030448140, + 3033016260, + 3035573460, + 3038119560, + 3040657260, + 3043189980, + 3045721020, + 3048253260, + 3050789940, + 3053334420, + 3055889700, + 3058456740, + 3061032180, + 3063609060, + 3066180240, + 3068741460, + 3071292120, + 3073833840, + 3076368840, + 3078900000, + 3081430740, + 3083964960, + 3086506680, + 3089058780, + 3091621440, + 3094192020, + 3096765600, + 3099337080, + 3101902260, + 3104458560, + 3107005260, + 3109543740, + 3112077180, + 3114609660, + 3117145320, + 3119686980, + 3122235840, + 3124791960, + 3127354320, + 3129921480, + 3132490500, + 3135057180, + 3137617260, + 3140168880, + 3142712820, + 3145252140, + 3147790320, + 3150329880, + 3152872080, + 3155417520, + 3157966920, + 3160521240, + 3163080780, + 3165643980, + 3168207540, + 3170768340, + 3173324700, + 3175876560, + 3178425000, + 3180970860, + 3183514980, + 3186058080, + 3188601480, + 3191146560, + 3193694400, + 3196245480, + 3198799560, + 3201356280, + 3203915280, + 3206475720, + 3209036040, + 3211593960, + 3214147740, + 3216697200, + 3219243360, + 3221787480, + 3224330340, + 3226872420, + 3229414560, + 3231958860, + 3234507840, + 3237063540, + 3239625720, + 3242191560, + 3244756980, + 3247318860, + 3249875880, + 3252427680, + 3254974140, + 3257515140, + 3260051880, + 3262586760, + 3265123680, + 3267666480, + 3270217680, + 3272777640, + 3275344560, + 3277915020, + 3280485060, + 3283050780, + 3285608820, + 3288157440, + 3290697180, + 3293230860, + 3295762260, + 3298295280, + 3300833280, + 3303378900, + 3305933820, + 3308498580, + 3311071500, + 3313647660, + 3316220040, + 3318782880, + 3321334020, + 3323875200, + 3326409660, + 3328940940, + 3331472220, + 3334006380, + 3336546780, + 3339096660, + 3341658180, + 3344230200, + 3346807320, + 3349381860, + 3351947940, + 3354503520, + 3357049260, + 3359587200, + 3362120040, + 3364650840, + 3367183200, + 3369721140, + 3372268200, + 3374825880, + 3377392800, + 3379965000, + 3382537500, + 3385105680, + 3387666360, + 3390217740, + 3392760240, + 3395296080, + 3397829040, + 3400363320, + 3402902280, + 3405447900, + 3408000420, + 3410559360, + 3413123520, + 3415691160, + 3418258860, + 3420822300, + 3423378240, + 3425926140, + 3428467980, + 3431007120, + 3433546620, + 3436088100, + 3438632220, + 3441179340, + 3443730480, + 3446286480, + 3448847100, + 3451410060, + 3453972060, + 3456530760, + 3459085320, + 3461636280, + 3464184360, + 3466730220, + 3469274340, + 3471817620, + 3474361440, + 3476907120, + 3479455620, + 3482007180, + 3484561680, + 3487119060, + 3489678960, + 3492240240, + 3494800620, + 3497357700, + 3499910220, + 3502458420, + 3505003440, + 3507546600, + 3510088380, + 3512629440, + 3515171340, + 3517716900, + 3520268700, + 3522827880, + 3525392940, + 3527959980, + 3530524920, + 3533085240, + 3535640040, + 3538189200, + 3540732540, + 3543270600, + 3545805180, + 3548339880, + 3550878780, + 3553425300, + 3555981120, + 3558545400, + 3561115260, + 3563686740, + 3566255700, + 3568818180, + 3571371480, + 3573915060, + 3576450780, + 3578982300, + 3581513640, + 3584048580, + 3586590120, + 3589140480, + 3591700740, + 3594270360, + 3596846040, + 3599421420, + 3601989540, + 3604546320, + 3607091760, + 3609628800, + 3612160980, + 3614691840, + 3617224320, + 3619761480, + 3622306500, + 3624862380, + 3627429720, + 3630005100, + 3632581620, + 3635152140, + 3637712640, + 3640262820, + 3642804300, + 3645339420, + 3647871120, + 3650402580, + 3652937760, + 3655480260, + 3658032780, + 3660595320, + 3663165060, + 3665737440, + 3668307660, + 3670871880, + 3673427820, + 3675974760, + 3678513960, + 3681048420, + 3683582220, + 3686119140, + 3688661700, + 3691210920, + 3693766440, + 3696327480, + 3698892720, + 3701460000, + 3704025480, + 3706585260, + 3709137420, + 3711682560, + 3714223500, + 3716763420, + 3719304480, + 3721847640, + 3724393200, + 3726941760, + 3729494400, + 3732051780, + 3734612880, + 3737175060, + 3739735500, + 3742292580, + 3744846060, + 3747396480, + 3749944380, + 3752490060, + 3755034000, + 3757577280, + 3760121280, + 3762667380, + 3765216300, + 3767768400, + 3770323800, + 3772882380, + 3775443540, + 3778005480, + 3780565560, + 3783121500, + 3785672400, + 3788219160, + 3790762920, + 3793304640, + 3795845100, + 3798385440, + 3800928060, + 3803475960, + 3806031240, + 3808594020, + 3811161360, + 3813728820, + 3816292560, + 3818850780, + 3821402940, + 3823948860, + 3826488840, + 3829024140, + 3831557640, + 3834093420, + 3836635500, + 3839186520, + 3841747140, + 3844315200, + 3846887160, + 3849458520, + 3852025020, + 3854583120, + 3857131080, + 3859669920, + 3862202580, + 3864733140, + 3867265620, + 3869803500, + 3872349360, + 3874904880, + 3877470420, + 3880044120, + 3882620760, + 3885193260, + 3887755740, + 3890306340, + 3892846800, + 3895380780, + 3897911820, + 3900443220, + 3902977800, + 3905518800, + 3908069280, + 3910631220, + 3913203240, + 3915779940, + 3918353820, + 3920919240, + 3923474220, + 3926019660, + 3928557660, + 3931090920, + 3933622440, + 3936155700, + 3938694600, + 3941242260, + 3943799940, + 3946366260, + 3948937320, + 3951508380, + 3954075420, + 3956635440, + 3959186880, + 3961729980, + 3964266900, + 3966801120, + 3969336720, + 3971876880, + 3974423040, + 3976975380, + 3979533180, + 3982095600, + 3984661260, + 3987227400, + 3989790120, + 3992346300, + 3994895280, + 3997438680, + 3999979560, + 4002520740, + 4005063420, + 4007607960, + 4010154600, + 4012704300, + 4015258320, + 4017816720, + 4020377940, + 4022939220, + 4025498220, + 4028054100, + 4030606920, + 4033157100, + 4035704760, + 4038250020, + 4040793540, + 4043336640, + 4045880820, + 4048427280, + 4050976800, + 4053529680, + 4056086220, + 4058646360, + 4061208960, + 4063771380, + 4066330620, + 4068884940, + 4071434040, + 4073979120, + 4076521380, + 4079061660, + 4081600980, + 4084141260, + 4086685440, + 4089236520, + 4091795880, + 4094362140, + 4096931040, + 4099497900, + 4102059660, ] @@ -226,8 +1256,10 @@ def get_last_full_moon(d): idx = bisect.bisect_right(fullmoons, now) if idx in [0, len(fullmoons)]: raise ValueError( - 'watson has only full moon dates from year 2000 to 2099, not {}' - .format(d.year)) + "watson has only full moon dates from year 2000 to 2099, not {}".format( + d.year + ) + ) last = fullmoons[idx - 1] return arrow.get(last) diff --git a/watson/utils.py b/watson/utils.py index c276860d..6815800f 100644 --- a/watson/utils.py +++ b/watson/utils.py @@ -18,7 +18,7 @@ def create_watson(): - return _watson.Watson(config_dir=os.environ.get('WATSON_DIR')) + return _watson.Watson(config_dir=os.environ.get("WATSON_DIR")) def confirm_project(project, watson_projects): @@ -29,8 +29,7 @@ def confirm_project(project, watson_projects): Returns True on accept and raises click.exceptions.Abort on reject """ if project not in watson_projects: - msg = ("Project '%s' does not exist yet. Create it?" - % style('project', project)) + msg = "Project '%s' does not exist yet. Create it?" % style("project", project) click.confirm(msg, abort=True) return True @@ -43,7 +42,7 @@ def confirm_tags(tags, watson_tags): """ for tag in tags: if tag not in watson_tags: - msg = "Tag '%s' does not exist yet. Create it?" % style('tag', tag) + msg = "Tag '%s' does not exist yet. Create it?" % style("tag", tag) click.confirm(msg, abort=True) return True @@ -51,24 +50,22 @@ def confirm_tags(tags, watson_tags): def style(name, element): def _style_tags(tags): if not tags: - return '' + return "" - return '[{}]'.format(', '.join( - style('tag', tag) for tag in tags - )) + return "[{}]".format(", ".join(style("tag", tag) for tag in tags)) def _style_short_id(id): - return style('id', id[:7]) + return style("id", id[:7]) formats = { - 'project': {'fg': 'magenta'}, - 'tags': _style_tags, - 'tag': {'fg': 'blue'}, - 'time': {'fg': 'green'}, - 'error': {'fg': 'red'}, - 'date': {'fg': 'cyan'}, - 'short_id': _style_short_id, - 'id': {'fg': 'white'} + "project": {"fg": "magenta"}, + "tags": _style_tags, + "tag": {"fg": "blue"}, + "time": {"fg": "green"}, + "error": {"fg": "red"}, + "date": {"fg": "cyan"}, + "short_id": _style_short_id, + "id": {"fg": "white"}, } fmt = formats.get(name, {}) @@ -92,17 +89,17 @@ def format_timedelta(delta): if total >= 3600: hours = seconds // 3600 - stems.append('{}h'.format(hours)) + stems.append("{}h".format(hours)) seconds -= hours * 3600 if total >= 60: mins = seconds // 60 - stems.append('{:02}m'.format(mins)) + stems.append("{:02}m".format(mins)) seconds -= mins * 60 - stems.append('{:02}s'.format(seconds)) + stems.append("{:02}s".format(seconds)) - return ('-' if neg else '') + ' '.join(stems) + return ("-" if neg else "") + " ".join(stems) def sorted_groupby(iterator, key, reverse=False): @@ -118,12 +115,17 @@ def options(opt_list): Wrapper for the `value_proc` field in `click.prompt`, which validates that the user response is part of the list of accepted responses. """ + def value_proc(user_input): if user_input in opt_list: return user_input else: - raise UsageError("Response should be one of [{}]".format( - ','.join(str(x) for x in opt_list))) + raise UsageError( + "Response should be one of [{}]".format( + ",".join(str(x) for x in opt_list) + ) + ) + return value_proc @@ -141,7 +143,7 @@ def get_frame_from_argument(watson, arg): return watson.frames[index] except IndexError: raise click.ClickException( - style('error', "No frame found for index {}.".format(arg)) + style("error", "No frame found for index {}.".format(arg)) ) except (ValueError, TypeError): pass @@ -150,9 +152,10 @@ def get_frame_from_argument(watson, arg): try: return watson.frames[arg] except KeyError: - raise click.ClickException("{} {}.".format( - style('error', "No frame found with id"), - style('short_id', arg)) + raise click.ClickException( + "{} {}.".format( + style("error", "No frame found with id"), style("short_id", arg) + ) ) @@ -167,21 +170,21 @@ def get_start_time_for_period(period): weekday = now.weekday() - if period == 'day': + if period == "day": start_time = arrow.Arrow(year, month, day) - elif period == 'week': + elif period == "week": start_time = arrow.Arrow.fromdate(now.shift(days=-weekday).date()) - elif period == 'month': + elif period == "month": start_time = arrow.Arrow(year, month, 1) - elif period == 'luna': + elif period == "luna": start_time = get_last_full_moon(now) - elif period == 'year': + elif period == "year": start_time = arrow.Arrow(year, 1, 1) - elif period == 'all': + elif period == "all": # approximately timestamp `0` start_time = arrow.Arrow(1970, 1, 1) else: - raise ValueError('Unsupported period value: {}'.format(period)) + raise ValueError("Unsupported period value: {}".format(period)) return start_time @@ -191,9 +194,20 @@ def apply_weekday_offset(start_time, week_start): Apply the offset required to move the start date `start_time` of a week starting on Monday to that of a week starting on `week_start`. """ - weekdays = dict(zip( - ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", - "sunday"], range(0, 7))) + weekdays = dict( + zip( + [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ], + range(0, 7), + ) + ) new_start = week_start.lower() if new_start not in weekdays: @@ -208,13 +222,15 @@ def make_json_writer(func, *args, **kwargs): Return a function that receives a file-like object and writes the return value of func(*args, **kwargs) as JSON to it. """ + def writer(f): dump = json.dumps(func(*args, **kwargs), indent=1, ensure_ascii=False) f.write(dump) + return writer -def safe_save(path, content, ext='.bak'): +def safe_save(path, content, ext=".bak"): """ Save given content to file at given path safely. @@ -230,7 +246,7 @@ def safe_save(path, content, ext='.bak'): the temporary file moved into its place. """ - tmpfp = tempfile.NamedTemporaryFile(mode='w+', delete=False) + tmpfp = tempfile.NamedTemporaryFile(mode="w+", delete=False) try: with tmpfp: if isinstance(content, str): @@ -260,9 +276,11 @@ def deduplicate(sequence): Leaves the input sequence unaltered. """ - return [element - for index, element in enumerate(sequence) - if element not in sequence[:index]] + return [ + element + for index, element in enumerate(sequence) + if element not in sequence[:index] + ] def parse_tags(values_list): @@ -272,14 +290,27 @@ def parse_tags(values_list): Find all the tags starting by a '+', even if there are spaces in them, then strip each tag and filter out the empty ones """ - return list(filter(None, map(operator.methodcaller('strip'), ( - # We concatenate the word with the '+' to the following words - # not starting with a '+' - w[1:] + ' ' + ' '.join(itertools.takewhile( - lambda s: not s.startswith('+'), values_list[i + 1:] - )) - for i, w in enumerate(values_list) if w.startswith('+') - )))) # pile of pancakes ! + return list( + filter( + None, + map( + operator.methodcaller("strip"), + ( + # We concatenate the word with the '+' to the following words + # not starting with a '+' + w[1:] + + " " + + " ".join( + itertools.takewhile( + lambda s: not s.startswith("+"), values_list[(i + 1) :] + ) + ) + for i, w in enumerate(values_list) + if w.startswith("+") + ), + ), + ) + ) # pile of pancakes ! def frames_to_json(frames): @@ -292,13 +323,15 @@ def frames_to_json(frames): .. seealso:: :class:`Frame` """ log = [ - co.OrderedDict([ - ('id', frame.id), - ('start', frame.start.isoformat()), - ('stop', frame.stop.isoformat()), - ('project', frame.project), - ('tags', frame.tags), - ]) + co.OrderedDict( + [ + ("id", frame.id), + ("start", frame.start.isoformat()), + ("stop", frame.stop.isoformat()), + ("project", frame.project), + ("tags", frame.tags), + ] + ) for frame in frames ] return json.dumps(log, indent=4, sort_keys=True) @@ -314,13 +347,15 @@ def frames_to_csv(frames): .. seealso:: :class:`Frame` """ entries = [ - co.OrderedDict([ - ('id', frame.id[:7]), - ('start', frame.start.format('YYYY-MM-DD HH:mm:ss')), - ('stop', frame.stop.format('YYYY-MM-DD HH:mm:ss')), - ('project', frame.project), - ('tags', ', '.join(frame.tags)), - ]) + co.OrderedDict( + [ + ("id", frame.id[:7]), + ("start", frame.start.format("YYYY-MM-DD HH:mm:ss")), + ("stop", frame.stop.format("YYYY-MM-DD HH:mm:ss")), + ("project", frame.project), + ("tags", ", ".join(frame.tags)), + ] + ) for frame in frames ] return build_csv(entries) @@ -336,7 +371,7 @@ def build_csv(entries): if entries: header = entries[0].keys() else: - return '' + return "" memfile = StringIO() writer = csv.DictWriter(memfile, header, lineterminator=os.linesep) writer.writeheader() @@ -366,24 +401,28 @@ def flatten_report_for_csv(report): of the report. """ result = [] - datetime_from = report['timespan']['from'].format('YYYY-MM-DD HH:mm:ss') - datetime_to = report['timespan']['to'].format('YYYY-MM-DD HH:mm:ss') - for project in report['projects']: - result.append({ - 'from': datetime_from, - 'to': datetime_to, - 'project': project['name'], - 'tag': '', - 'time': project['time'] - }) - for tag in project['tags']: - result.append({ - 'from': datetime_from, - 'to': datetime_to, - 'project': project['name'], - 'tag': tag['name'], - 'time': tag['time'] - }) + datetime_from = report["timespan"]["from"].format("YYYY-MM-DD HH:mm:ss") + datetime_to = report["timespan"]["to"].format("YYYY-MM-DD HH:mm:ss") + for project in report["projects"]: + result.append( + { + "from": datetime_from, + "to": datetime_to, + "project": project["name"], + "tag": "", + "time": project["time"], + } + ) + for tag in project["tags"]: + result.append( + { + "from": datetime_from, + "to": datetime_to, + "project": project["name"], + "tag": tag["name"], + "time": tag["time"], + } + ) return result diff --git a/watson/watson.py b/watson/watson.py index 7ab7da12..3c062d1a 100644 --- a/watson/watson.py +++ b/watson/watson.py @@ -47,22 +47,21 @@ def __init__(self, **kwargs): self._config = None self._config_changed = False - self._dir = (kwargs.pop('config_dir', None) or - click.get_app_dir('watson')) + self._dir = kwargs.pop("config_dir", None) or click.get_app_dir("watson") - self.config_file = os.path.join(self._dir, 'config') - self.frames_file = os.path.join(self._dir, 'frames') - self.state_file = os.path.join(self._dir, 'state') - self.last_sync_file = os.path.join(self._dir, 'last_sync') + self.config_file = os.path.join(self._dir, "config") + self.frames_file = os.path.join(self._dir, "frames") + self.state_file = os.path.join(self._dir, "state") + self.last_sync_file = os.path.join(self._dir, "last_sync") - if 'frames' in kwargs: - self.frames = kwargs['frames'] + if "frames" in kwargs: + self.frames = kwargs["frames"] - if 'current' in kwargs: - self.current = kwargs['current'] + if "current" in kwargs: + self.current = kwargs["current"] - if 'last_sync' in kwargs: - self.last_sync = kwargs['last_sync'] + if "last_sync" in kwargs: + self.last_sync = kwargs["last_sync"] def _load_json_file(self, filename, type=dict): """ @@ -82,19 +81,15 @@ def _load_json_file(self, filename, type=dict): if os.path.getsize(filename) == 0: return type() else: - raise WatsonError( - "Invalid JSON file {}: {}".format(filename, e) - ) + raise WatsonError("Invalid JSON file {}: {}".format(filename, e)) except Exception as e: raise WatsonError( - "Unexpected error while loading JSON file {}: {}".format( - filename, e - ) + "Unexpected error while loading JSON file {}: {}".format(filename, e) ) def _parse_date(self, date): """Returns Arrow object from timestamp.""" - return arrow.Arrow.utcfromtimestamp(date).to('local') + return arrow.Arrow.utcfromtimestamp(date).to("local") def _format_date(self, date): """Returns timestamp from string timestamp or Arrow object.""" @@ -113,8 +108,7 @@ def config(self): config = ConfigParser() config.read(self.config_file) except CFGParserError as e: - raise ConfigurationError( - "Cannot parse config file: {}".format(e)) + raise ConfigurationError("Cannot parse config file: {}".format(e)) self._config = config @@ -139,9 +133,9 @@ def save(self): if self._current is not None and self._old_state != self._current: if self.is_started: current = { - 'project': self.current['project'], - 'start': self._format_date(self.current['start']), - 'tags': self.current['tags'], + "project": self.current["project"], + "start": self._format_date(self.current["start"]), + "tags": self.current["tags"], } else: current = {} @@ -150,19 +144,18 @@ def save(self): self._old_state = current if self._frames is not None and self._frames.changed: - safe_save(self.frames_file, - make_json_writer(self.frames.dump)) + safe_save(self.frames_file, make_json_writer(self.frames.dump)) if self._config_changed: safe_save(self.config_file, self.config.write) if self._last_sync is not None: - safe_save(self.last_sync_file, - make_json_writer(self._format_date, self.last_sync)) + safe_save( + self.last_sync_file, + make_json_writer(self._format_date, self.last_sync), + ) except OSError as e: - raise WatsonError( - "Impossible to write {}: {}".format(e.filename, e) - ) + raise WatsonError("Impossible to write {}: {}".format(e.filename, e)) @property def frames(self): @@ -187,7 +180,7 @@ def current(self): @current.setter def current(self, value): - if not value or 'project' not in value: + if not value or "project" not in value: self._current = {} if self._old_state is None: @@ -195,15 +188,15 @@ def current(self, value): return - start = value.get('start', arrow.now()) + start = value.get("start", arrow.now()) if not isinstance(start, arrow.Arrow): start = self._parse_date(start) self._current = { - 'project': value['project'], - 'start': start, - 'tags': value.get('tags') or [] + "project": value["project"], + "start": start, + "tags": value.get("tags") or [], } if self._old_state is None: @@ -212,9 +205,7 @@ def current(self, value): @property def last_sync(self): if self._last_sync is None: - self.last_sync = self._load_json_file( - self.last_sync_file, type=int - ) + self.last_sync = self._load_json_file(self.last_sync_file, type=int) return self._last_sync @@ -239,22 +230,19 @@ def add(self, project, from_date, to_date, tags): if from_date > to_date: raise WatsonError("Task cannot end before it starts.") - default_tags = self.config.getlist('default_tags', project) + default_tags = self.config.getlist("default_tags", project) tags = (tags or []) + default_tags frame = self.frames.add(project, from_date, to_date, tags=tags) return frame - def start(self, project, tags=None, restart=False, start_at=None, - gap=True): + def start(self, project, tags=None, restart=False, start_at=None, gap=True): if self.is_started: raise WatsonError( - "Project {} is already started.".format( - self.current['project'] - ) + "Project {} is already started.".format(self.current["project"]) ) - default_tags = self.config.getlist('default_tags', project) + default_tags = self.config.getlist("default_tags", project) if not restart: tags = (tags or []) + default_tags @@ -265,16 +253,15 @@ def start(self, project, tags=None, restart=False, start_at=None, # and previous frames exist stop_of_prev_frame = self.frames[-1].stop if start_at < stop_of_prev_frame: - raise WatsonError('Task cannot start before the previous task ' - 'ends.') + raise WatsonError("Task cannot start before the previous task " "ends.") if start_at > arrow.now(): - raise WatsonError('Task cannot start in the future.') + raise WatsonError("Task cannot start in the future.") - new_frame = {'project': project, 'tags': deduplicate(tags)} - new_frame['start'] = start_at + new_frame = {"project": project, "tags": deduplicate(tags)} + new_frame["start"] = start_at if not gap: stop_of_prev_frame = self.frames[-1].stop - new_frame['start'] = stop_of_prev_frame + new_frame["start"] = stop_of_prev_frame self.current = new_frame return self.current @@ -291,14 +278,12 @@ def stop(self, stop_at=None): # stop function and calling it, the value of `stop_at` could be # outdated if defined using a default argument. stop_at = arrow.now() - if old['start'] > stop_at: - raise WatsonError('Task cannot end before it starts.') + if old["start"] > stop_at: + raise WatsonError("Task cannot end before it starts.") if stop_at > arrow.now(): - raise WatsonError('Task cannot end in the future.') + raise WatsonError("Task cannot end in the future.") - frame = self.frames.add( - old['project'], old['start'], stop_at, tags=old['tags'] - ) + frame = self.frames.add(old["project"], old["start"], stop_at, tags=old["tags"]) self.current = None return frame @@ -316,26 +301,23 @@ def projects(self): """ Return the list of all the existing projects, sorted by name. """ - return sorted(set(self.frames['project'])) + return sorted(set(self.frames["project"])) @property def tags(self): """ Return the list of the tags, sorted by name. """ - return sorted(set(tag for tags in self.frames['tags'] for tag in tags)) + return sorted(set(tag for tags in self.frames["tags"] for tag in tags)) def _get_request_info(self, route): config = self.config - dest = config.get('backend', 'url') - token = config.get('backend', 'token') + dest = config.get("backend", "url") + token = config.get("backend", "token") if dest and token: - dest = "{}/{}/".format( - dest.rstrip('/'), - route.strip('/') - ) + dest = "{}/{}/".format(dest.rstrip("/"), route.strip("/")) else: raise ConfigurationError( "You must specify a remote URL (backend.url) and a token " @@ -343,8 +325,8 @@ def _get_request_info(self, route): ) headers = { - 'content-type': 'application/json', - 'Authorization': "Token {}".format(token) + "content-type": "application/json", + "Authorization": "Token {}".format(token), } return dest, headers @@ -352,8 +334,9 @@ def _get_request_info(self, route): def _get_remote_projects(self): # import when required in order to reduce watson response time (#312) import requests - if not hasattr(self, '_remote_projects'): - dest, headers = self._get_request_info('projects') + + if not hasattr(self, "_remote_projects"): + dest, headers = self._get_request_info("projects") try: response = requests.get(dest, headers=headers) @@ -368,15 +351,16 @@ def _get_remote_projects(self): "server: {}".format(response.json()) ) - return self._remote_projects['projects'] + return self._remote_projects["projects"] def pull(self): import requests - dest, headers = self._get_request_info('frames') + + dest, headers = self._get_request_info("frames") try: response = requests.get( - dest, params={'last_sync': self.last_sync}, headers=headers + dest, params={"last_sync": self.last_sync}, headers=headers ) assert response.status_code == 200 except requests.ConnectionError: @@ -390,31 +374,34 @@ def pull(self): frames = response.json() or () for frame in frames: - frame_id = uuid.UUID(frame['id']).hex + frame_id = uuid.UUID(frame["id"]).hex self.frames[frame_id] = ( - frame['project'], - frame['begin_at'], - frame['end_at'], - frame['tags'] + frame["project"], + frame["begin_at"], + frame["end_at"], + frame["tags"], ) return frames def push(self, last_pull): import requests - dest, headers = self._get_request_info('frames/bulk') + + dest, headers = self._get_request_info("frames/bulk") frames = [] for frame in self.frames: if last_pull > frame.updated_at > self.last_sync: - frames.append({ - 'id': uuid.UUID(frame.id).urn, - 'begin_at': str(frame.start.to('utc')), - 'end_at': str(frame.stop.to('utc')), - 'project': frame.project, - 'tags': frame.tags - }) + frames.append( + { + "id": uuid.UUID(frame.id).urn, + "begin_at": str(frame.start.to("utc")), + "end_at": str(frame.stop.to("utc")), + "project": frame.project, + "tags": frame.tags, + } + ) try: response = requests.post(dest, json.dumps(frames), headers=headers) @@ -424,17 +411,15 @@ def push(self, last_pull): except AssertionError: raise WatsonError( "An error occurred with the remote server (status: {}). " - "Response was:\n{}".format( - response.status_code, - response.text - ) + "Response was:\n{}".format(response.status_code, response.text) ) return frames def merge_report(self, frames_with_conflict): - conflict_file_frames = Frames(self._load_json_file( - frames_with_conflict, type=list)) + conflict_file_frames = Frames( + self._load_json_file(frames_with_conflict, type=list) + ) conflicting = [] merging = [] @@ -455,19 +440,33 @@ def merge_report(self, frames_with_conflict): def _validate_report_options(self, filtrate, ignored): return not bool( - filtrate and ignored and set(filtrate).intersection(set(ignored))) - - def report(self, from_, to, current=None, projects=None, tags=None, - ignore_projects=None, ignore_tags=None, year=None, - month=None, week=None, day=None, luna=None, all=None, - include_partial_frames=False): - for start_time in (_ for _ in [day, week, month, year, luna, all] - if _ is not None): + filtrate and ignored and set(filtrate).intersection(set(ignored)) + ) + + def report( + self, + from_, + to, + current=None, + projects=None, + tags=None, + ignore_projects=None, + ignore_tags=None, + year=None, + month=None, + week=None, + day=None, + luna=None, + all=None, + include_partial_frames=False, + ): + for start_time in ( + _ for _ in [day, week, month, year, luna, all] if _ is not None + ): from_ = start_time if not self._validate_report_options(projects, ignore_projects): - raise WatsonError( - "given projects can't be ignored at the same time") + raise WatsonError("given projects can't be ignored at the same time") if not self._validate_report_options(tags, ignore_tags): raise WatsonError("given tags can't be ignored at the same time") @@ -476,76 +475,80 @@ def report(self, from_, to, current=None, projects=None, tags=None, raise WatsonError("'from' must be anterior to 'to'") if current is None: - current = self.config.getboolean('options', 'report_current') + current = self.config.getboolean("options", "report_current") if self.current and current: cur = self.current - self.frames.add(cur['project'], cur['start'], arrow.utcnow(), - cur['tags'], id="current") + self.frames.add( + cur["project"], cur["start"], arrow.utcnow(), cur["tags"], id="current" + ) span = self.frames.span(from_, to) frames_by_project = sorted_groupby( self.frames.filter( - projects=projects or None, tags=tags or None, + projects=projects or None, + tags=tags or None, ignore_projects=ignore_projects or None, ignore_tags=ignore_tags or None, - span=span, include_partial_frames=include_partial_frames, + span=span, + include_partial_frames=include_partial_frames, ), - operator.attrgetter('project') + operator.attrgetter("project"), ) if self.current and current: - del self.frames['current'] + del self.frames["current"] total = datetime.timedelta() report = { - 'timespan': { - 'from': span.start, - 'to': span.stop, - }, - 'projects': [] - } + "timespan": { + "from": span.start, + "to": span.stop, + }, + "projects": [], + } for project, frames in frames_by_project: frames = tuple(frames) delta = reduce( - operator.add, - (f.stop - f.start for f in frames), - datetime.timedelta() + operator.add, (f.stop - f.start for f in frames), datetime.timedelta() ) total += delta project_report = { - 'name': project, - 'time': delta.total_seconds(), - 'tags': [] + "name": project, + "time": delta.total_seconds(), + "tags": [], } if tags is None: tags = [] tags_to_print = sorted( - set(tag for frame in frames for tag in frame.tags - if tag in tags or not tags) + set( + tag + for frame in frames + for tag in frame.tags + if tag in tags or not tags + ) ) for tag in tags_to_print: delta = reduce( operator.add, (f.stop - f.start for f in frames if tag in f.tags), - datetime.timedelta() + datetime.timedelta(), ) - project_report['tags'].append({ - 'name': tag, - 'time': delta.total_seconds() - }) + project_report["tags"].append( + {"name": tag, "time": delta.total_seconds()} + ) - report['projects'].append(project_report) + report["projects"].append(project_report) - report['time'] = total.total_seconds() + report["time"] = total.total_seconds() return report def rename_project(self, old_project, new_project): @@ -558,8 +561,7 @@ def rename_project(self, old_project, new_project): for frame in self.frames: if frame.project == old_project: self.frames[frame.id] = frame._replace( - project=new_project, - updated_at=updated_at + project=new_project, updated_at=updated_at ) self.frames.changed = True @@ -576,7 +578,7 @@ def rename_tag(self, old_tag, new_tag): if old_tag in frame.tags: self.frames[frame.id] = frame._replace( tags=[new_tag if t == old_tag else t for t in frame.tags], - updated_at=updated_at + updated_at=updated_at, ) self.frames.changed = True From 51fed02d3416f6d2ef1bf8437d952e0821a3486f Mon Sep 17 00:00:00 2001 From: Dirk Doesburg Date: Fri, 24 Jun 2022 19:53:53 +0200 Subject: [PATCH 3/4] Update documentation --- docs/contributing/hack.md | 21 ++------------------- docs/contributing/pr-guidelines.md | 7 +++---- 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/docs/contributing/hack.md b/docs/contributing/hack.md index d490bc1d..c306fa26 100644 --- a/docs/contributing/hack.md +++ b/docs/contributing/hack.md @@ -52,10 +52,9 @@ Ready to contribute? Here's how to set up *Watson* for local development. inside the virtual environment. You can run `watson projects` to check that your real projects are not shown. -6. When you're done making changes, check that your changes pass the tests - (see [Run the tests](#run-the-tests)): +6. When you're done making changes, check that your changes pass the tests: - (.venv) $ tox + (.venv) $ pytest tests 7. If you have added a new command or updated/fixed docstrings, please update the documentation: @@ -70,19 +69,3 @@ Ready to contribute? Here's how to set up *Watson* for local development. 9. After [reading this](./pr-guidelines.md), submit a pull request through the GitHub website. - - -## Run the tests - -The tests use [pytest](http://pytest.org/). To run them with the default Python -interpreter: - - $ py.test -v tests/ - -To run the tests via [tox](http://tox.testrun.org/) with all Python versions -which are available on your system and are defined in the `tox.ini` file, -simply run: - - $ tox - -This will also check the source code with [flake8](http://flake8.pycqa.org). diff --git a/docs/contributing/pr-guidelines.md b/docs/contributing/pr-guidelines.md index 1a42bdd7..e25b769e 100644 --- a/docs/contributing/pr-guidelines.md +++ b/docs/contributing/pr-guidelines.md @@ -3,9 +3,8 @@ > *nota bene* > > Open a pull-request even if your contribution is not ready yet! It can -> be discussed and improved collaboratively! You may prefix the title of -> your pull-request with "WIP: " to make it clear that it is not yet ready -> for merging. +> be discussed and improved collaboratively! You can create a draft PR to +> indicate that it is not finished yet. Before we merge a pull request, we check that it meets these guidelines: @@ -24,7 +23,7 @@ Before we merge a pull request, we check that it meets these guidelines: 4. The pull request **should** include tests. 5. If the pull request adds functionality, the docs **should** be updated. -6. *TravisCI* integration tests should be **green** :) It will make +6. GitHub linting and testing workflows should be **green** :) They will make sure the tests pass with every supported version of Python. Thank you! From aae44fa8116a04328ee22b46e9f1632b717fc20a Mon Sep 17 00:00:00 2001 From: Dirk Doesburg Date: Fri, 24 Jun 2022 22:17:35 +0200 Subject: [PATCH 4/4] Add workflow to simplify deployment procedure --- .github/workflows/deploy-documentation.yaml | 35 +++++++++++++++++++++ docs/contributing/release-new-version.md | 30 ++---------------- 2 files changed, 38 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/deploy-documentation.yaml diff --git a/.github/workflows/deploy-documentation.yaml b/.github/workflows/deploy-documentation.yaml new file mode 100644 index 00000000..a8d2e3cd --- /dev/null +++ b/.github/workflows/deploy-documentation.yaml @@ -0,0 +1,35 @@ +name: Deploy + +on: + release: + types: [released] + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + steps: + + - name: Checkout the repository + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: "3.9" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Build documentation + run: python scripts/gen-cli-docs.py + + - name: Deploy docs + run: mkdocs gh-deploy --clean + + - name: Deploy to PyPI + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/docs/contributing/release-new-version.md b/docs/contributing/release-new-version.md index e93f9ba3..898f45f8 100644 --- a/docs/contributing/release-new-version.md +++ b/docs/contributing/release-new-version.md @@ -32,36 +32,12 @@ $ git push origin --tags Create a new pull request (PR) with the `prepare-x.y.z` branch. You can safely merge this PR if all tests are green. -Draft a new [Watson Release on +Create and publish a new [Watson Release on GitHub](https://github.com/TailorDev/Watson/releases) with the same release notes. -## Push the `x.y.z` release to PyPI - -Checkout the up-to-date `master` branch: - -```bash -$ git checkout master -$ git pull --rebase origin master -``` - -Now, build the release and submit it to PyPI using -[twine](https://github.com/pypa/twine) (you'll need to be registered as a -maintainer of the package): - -```bash -$ python setup.py sdist bdist_wheel -$ twine upload dist/* -``` - -## Update online documentation - -We use [`mkdocs`](http://www.mkdocs.org) to generate the online documentation. -It must be updated via: - -```bash -$ mkdocs gh-deploy --clean -``` +A GitHub workflow will now automatically update the online documentation, and publish +the release to PyPI. ## Publish the `x.y.z` release to Homebrew