From e9448d650bbbe7423f19b8414b0d21ba405dcb2e Mon Sep 17 00:00:00 2001 From: Mike Cantelon Date: Tue, 8 Sep 2020 00:12:36 -0700 Subject: [PATCH] Update to use new API, linting, cleanup --- .github/workflows/test.yml | 25 ++++ .markdownlint.jsonc | 11 ++ .pre-commit-config.yaml | 23 +++ MANIFEST.in | 1 + README.md | 122 +++------------- bin/cft | 21 +-- bump | 34 +++++ clockifytool/__init__.py | 2 +- clockifytool/api.py | 249 ++++++++++++++++---------------- clockifytool/app.py | 20 ++- clockifytool/cli.py | 171 ++++++++++++---------- clockifytool/commands.py | 193 +++++++++++-------------- clockifytool/helpers.py | 281 +++++++++++++++++++++++-------------- requirements.txt | 7 +- requirements/base.txt | 6 + requirements/test.txt | 4 + setup.py | 36 ++--- tox.ini | 37 +++++ 18 files changed, 691 insertions(+), 552 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 .markdownlint.jsonc create mode 100644 .pre-commit-config.yaml create mode 100644 MANIFEST.in create mode 100755 bump create mode 100644 requirements/base.txt create mode 100644 requirements/test.txt create mode 100644 tox.ini diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..504fe4d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +--- +name: "Test" +on: + pull_request: + push: + branches: + - "main" +jobs: + lint: + name: "Lint" + runs-on: "ubuntu-22.04" + steps: + - name: "Check out repository" + uses: "actions/checkout@v3" + - name: "Set up Python" + uses: "actions/setup-python@v3" + with: + python-version: "3.8" + - name: "Install tox" + run: | + python -m pip install --upgrade pip + pip install tox + - name: "Run tox" + run: | + tox -e linting diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc new file mode 100644 index 0000000..10c16f5 --- /dev/null +++ b/.markdownlint.jsonc @@ -0,0 +1,11 @@ +{ + "default": true, + "MD010": { // Presence of hard tabs + "code_blocks": false // Don't check code blocks + }, + "MD013": { // Limit line lengths + "code_blocks": false, // Don't check code blocks + "tables": false // Don't check table Markdown + }, + "MD041": false // Disable: first line in a file should be a top-level heading +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..68f24ff --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +repos: +- repo: https://github.com/ambv/black + rev: 18.9b0 + hooks: + - id: black + args: [--safe, --quiet] + language_version: python3 +- repo: https://github.com/PyCQA/flake8 + rev: 6.1.0 + hooks: + - id: flake8 + language_version: python3 + additional_dependencies: ['flake8-builtins==2.2.0'] +- repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + language_version: python3 +- repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.37.0 + hooks: + - id: markdownlint + args: ["--config", ".markdownlint.jsonc"] diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..12f28f5 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include requirements/base.txt diff --git a/README.md b/README.md index 6fa04d1..945f9d6 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,9 @@ configuration`) to rapidly create time entries. For example if you regularly have a 15 minute scrum meeting you could create a "scrum" time entry template that allows you to add a time entry like this: - $ ./cft +scrum + ./cft +scrum - -Known issues ------------- +## Known issues Clockify's API requires that all important details about a time entry be provided when making a change to a time entry. Because of this `cft`, when @@ -28,22 +26,18 @@ changed. This will likely be added in the future. Tested with Python 2.7 and 3. Will need to be tweaked to handle Unicode. - -Installation ------------- +## Installation Install via PyPi: - $ pip install clockifytool + pip install clockifytool ...Or clone this repo, change into the repo directory, then enter the following command: - $ pip install -r requirements.txt - + pip install -r requirements/base.txt -Basic Configuration -------------------- +## Basic Configuration To use `cft` you'll need to, in the Clockify web UI, click the "GENERATE" button on the "Personal settings" page to generate an API key. You'll then need @@ -76,14 +70,11 @@ Read on to learn about the basic functionality of `cft` and, once you've got the hang of things, check out `Advanced Configuration` to learn how you can save time when entering new time entries. - -Commands --------- +## Commands Clockify Tool allows you to list, create and delete Clockify time entries. You can also list projects, project tasks, and workspaces to find out their IDs. - ### Listing time entries in a period of time Help for the list command: @@ -119,11 +110,11 @@ Help for the list command: Example list of today's time entries: - $ ./cft + ./cft Here's example output: - $ ./cft + ./cft Fetching time entries from 2020-02-13 (Thursday)... Time entries: @@ -146,7 +137,7 @@ project of any entry that is associated with a task rather than a project. Here's an example of listing yesterday's time entries: - $ ./cft list yesterday + ./cft list yesterday Yesterday is one time period of a number of available time periods. @@ -156,13 +147,12 @@ for "yesterday" is "y", for example. `cft` commands like "list" can have one letter abbreviations. So if you wanted to list yesterday's time entries you could enter: - $ ./cft l y + ./cft l y Another time saver: if you enter a time period, instead of a command, you'll get a list of entries in the time period: - $ ./cft y - + ./cft y ### List time entries in an arbitrary date range @@ -174,7 +164,7 @@ one that's omitted they will default to today's date. Example list of time entries in an arbitary date range: - $ ./cft l -s 2019-03-06 --e 2019-03-09 + ./cft l -s 2019-03-06 --e 2019-03-09 The `-` or `+` operators, as a prefix to a integeter represeting a number of days, can also be used to indicate a relative date. @@ -182,8 +172,7 @@ days, can also be used to indicate a relative date. For example, if one wanted to list time entries created five days ago to the present day then one could use this command: - $ ./cft l -s -5 - + ./cft l -s -5 ### List projects @@ -196,7 +185,6 @@ For example: * Email [5cdb08621080ec2d4a8e707e] * Meetings [5cdb08ead278ae206156ae6f] - ### Project details The `project` (or `pd`) command is used to display details about a project, @@ -211,7 +199,6 @@ For example: Tasks: * Support [5d8bff9dad3d0047ca62e3fd] - ### Task details The `task` (or `td`) command is used to display details about a task. @@ -222,7 +209,6 @@ For example: Name: Support Project ID: ed5c600955e74cce9648cd91 - ### Creating a time entry The `new` (or `n`) command is used to create a new time entry. The number of @@ -249,11 +235,11 @@ Help for the new command: Here's an example (in which `5cb772f3f15c9857ee275d00` is the project ID: - $ ./cft new 5cb772f3f15c9857ee275d00 --comments="Checking email." --hours=.25 + ./cft new 5cb772f3f15c9857ee275d00 --comments="Checking email." --hours=.25 Here's the same example in a briefer form. - $ ./cft n 5cb772f3f15c9857ee275d00 -c "Checking email." -t .25 + ./cft n 5cb772f3f15c9857ee275d00 -c "Checking email." -t .25 When specifying a date, the `+` or `-` operators are relative to the current date. If you create a time entry today that should be dated as yesteray you @@ -261,83 +247,27 @@ could update it with `-1` as the date to fix. For example: - $ ./cft n 5cb772f3f15c9857ee275d00 -c "Checking email." -t .25 -d -1 + ./cft n 5cb772f3f15c9857ee275d00 -c "Checking email." -t .25 -d -1 If specifying a start time, using the `--start`/`-s` optional argument, the time should be specified in 24 hour time format. For example: - $ ./cft n 5cb772f3f15c9857ee275d00 -c "Checking email." -t .25 -s 13:15 + ./cft n 5cb772f3f15c9857ee275d00 -c "Checking email." -t .25 -s 13:15 Note that when adding a time entry the current time will be used as the start time unless a date and/or start time are specified. If a date is specified, but a start time isn't, then the start time will be midnight. If a start time is specified, however, then the specified start time will be used. - -### Updating a time entry - -The `update` (or `u`) command is used to update an existing time entry. - -Help for the update command: - - $ ./cft update -h - usage: cft update [-h] [-c comments: required for new time entries] - [-t hours spent: required for new time entries] [-d date] - [-b] [-a append: append text to comments] [-u] - entry ID - - positional arguments: - entry ID ID of time entry: required - - optional arguments: - -h, --help show this help message and exit - -c comments: required for new time entries, --comments comments: required for new time entries - -t hours spent: required for new time entries, --hours hours spent: required for new time entries - -d date, --date date defaults to today - -b, --billable - -a append: append text to comments, --append append: append text to comments - -u, --unbillable - -Here's an example (in which `5ce54a35a02987296634c98a` is the time entry's ID: - - $ ./cft update 5ce54a35a02987296634c98a --comments="Changed tires." --hours=1.5 - -Comments can be amended, rather than replaced, using the `--append` option. - -For example: - - $ ./cft u 5ce54a35a02987296634c98a -a "I also fixed the flux capacitor." - -When updating hours worked, the `+` or `-` operators can be used to increment -or decrement the entry's current hours worked value. - -For example: - - $ ./cft u 5ce54a35a02987296634c98a -t +.25 - -When adjusting a date, the `+` or `-` operators are relative to the current -date. If you added something today that should be dated as yesteray you could -update it with `-1` as the date to fix. - -For example: - - $ ./cft u 5ce54a35a02987296634c98a -d -1 - -Note: make sure you read the intro to this README file so you know that there -can be issues with updating time entries if you use both Clockify's web UI and -`cft`. - - ### Deleting a time entry The `delete` (or `d`) command is used to delete a time entry. Here's an example (in which `5cd64137b079870300a9c9e0` is the time entry ID: - $ ./cft delete 5cd64137b079870300a9c9e0 - + ./cft delete 5cd64137b079870300a9c9e0 ### List workspaces @@ -349,16 +279,13 @@ For example: $ ./cft workspaces * Client-Project-Task Workspace [4c31a29da059321c02e301e0] - ### Cache status/flushing You probably won't need to use this, but it exists. The `cache` command is used to display how many time entries have been cached. The `--flush` (or `-f`) flag can be used to delete all cached time entries. - -Advanced configuration ----------------------- +## Advanced configuration You can save time entering time entries by using advanced configuration. @@ -376,7 +303,6 @@ Example: meeting: id: meet - ### Project time entry templates In addition to using an alias to specify a project, or project task, ID, you @@ -402,12 +328,10 @@ overrride the --time command-line option. Example: - $ ./cft n scrum -t .5 - + ./cft n scrum -t .5 -Shortcuts and abbreviations ---------------------------- +### Shortcuts and abbreviations Example of quick addition of a time entry using a template: - $ ./cft +scrum + ./cft +scrum diff --git a/bin/cft b/bin/cft index e0b787e..a5f04f4 100755 --- a/bin/cft +++ b/bin/cft @@ -1,22 +1,23 @@ #!/usr/bin/env python from __future__ import print_function + import os import sys sys.path.append(os.path.join(os.path.dirname(os.path.realpath(__file__)), os.pardir)) +from clockifytool import __version__ as VERSION +from clockifytool import app, cli, commands from clockifytool.api import ClockifyApi -from clockifytool import app, cli, commands, __version__ as VERSION - # Parse CLI arguments parser = cli.arg_parser() args = parser.parse_args(cli.preprocess_argv()) # Display version if need be (config might not exist yet) -if args.command == 'version': - print('clockifytool version {}'.format(VERSION)) +if args.command == "version": + print("clockifytool version {}".format(VERSION)) sys.exit(0) # Load configuration @@ -27,21 +28,21 @@ except Exception as e: sys.exit(1) # Authenticate -clockify = ClockifyApi(config['api key']) +clockify = ClockifyApi(config["api key"]) # Display available workspaces or set workspace -if 'workspace' not in config: - config_path = os.path.join(os.path.expanduser('~'), config['filename']) +if "workspace" not in config: + config_path = os.path.join(os.path.expanduser("~"), config["filename"]) print('Please set workspace ID as "workspace" in {}.'.format(config_path)) print("\nAvailable workspaces:") - commands.list_workspaces(None, None, {'clockify': clockify}) + commands.list_workspaces(None, None, {"clockify": clockify}) sys.exit(1) else: - clockify.set_workspace(config['workspace']) + clockify.set_workspace(config["workspace"]) # Validate CLI arguments and execute command args = cli.validate_args(parser, args, config) command_function = getattr(commands, args.func) -app_data = {'clockify': clockify} +app_data = {"clockify": clockify} command_function(args, config, app_data) diff --git a/bump b/bump new file mode 100755 index 0000000..1287d24 --- /dev/null +++ b/bump @@ -0,0 +1,34 @@ +#!/bin/bash +CURRENT_TAG=`git describe --abbrev=0` +CURRENT_VERSION="${CURRENT_TAG:1}" + +echo "Current version: $CURRENT_VERSION" +echo + +echo "Bump type [patch|minor|major, default: patch]?" +read BUMPTYPE + +if [ -z "$BUMPTYPE" ] +then + BUMPTYPE="patch" +fi + +echo "Bump type: $BUMPTYPE" +echo + +bumpversion --current-version $CURRENT_VERSION $BUMPTYPE --commit --tag --tag-message="Release {new_version}" clockifytool/__init__.py setup.py + +echo "Pushing commit..." +git push + +echo "Pushing new tag..." +NEW_TAG=`git describe --abbrev=0` +git push origin $NEW_TAG + +echo "Packaging..." +rm dist/* +python setup.py sdist bdist_wheel +python3 setup.py sdist bdist_wheel + +echo "Publishing..." +twine upload dist/* diff --git a/clockifytool/__init__.py b/clockifytool/__init__.py index 27fdca4..81f0fde 100644 --- a/clockifytool/__init__.py +++ b/clockifytool/__init__.py @@ -1 +1 @@ -__version__ = "0.0.3" +__version__ = "0.0.4" diff --git a/clockifytool/api.py b/clockifytool/api.py index 7de52fe..eaf5b68 100644 --- a/clockifytool/api.py +++ b/clockifytool/api.py @@ -1,10 +1,11 @@ -from datetime import datetime, timedelta -import dateutil.parser import json import os -import pytz import tempfile +from datetime import datetime, timedelta + +import dateutil.parser import isodate +import pytz import requests from tzlocal import get_localzone @@ -13,7 +14,9 @@ class Iso8601DateConverter(object): def __init__(self): self.tz = get_localzone() - def add_hours_to_localized_datetime_and_convert_to_iso_8601(self, localized_datetime, hours): + def add_hours_to_localized_datetime_and_convert_to_iso_8601( + self, localized_datetime, hours + ): new_localized_datetime = localized_datetime + timedelta(hours=float(hours)) utc_datetime = new_localized_datetime.astimezone(pytz.utc) return isodate.datetime_isoformat(utc_datetime) @@ -23,7 +26,7 @@ def utc_iso_8601_string_to_local_datetime(self, utc_date_string): def utc_iso_8601_string_to_local_datatime_string(self, utc_date_string): local_datetime = self.utc_iso_8601_string_to_local_datetime(utc_date_string) - return local_datetime.strftime('%Y-%m-%d %H:%M:%S') + return local_datetime.strftime("%Y-%m-%d %H:%M:%S") def iso_duration_to_hours(self, duration): minutes = isodate.parse_duration(duration).total_seconds() / 60 @@ -48,39 +51,55 @@ def __init__(self): super(ClockifyEntryCacheManager, self).__init__() def get_cache_directory(self): - cache_dir = os.path.join(tempfile.gettempdir(), 'cft') + cache_dir = os.path.join(tempfile.gettempdir(), "cft") if not os.path.isdir(cache_dir): os.mkdir(cache_dir) return cache_dir - def get_cache_filepath(self, identifier): - return os.path.join(self.get_cache_directory(), 'cft-{}'.format(identifier)) + def get_cache_filepath(self, identifier, prefix=None): + + if prefix is not None: + identifier = prefix + "-" + identifier + + return os.path.join(self.get_cache_directory(), "cft-{}".format(identifier)) + + def create(self, data, identifier=None, prefix=None): + if identifier is None: + identifier = data["id"] + + filepath = self.get_cache_filepath(identifier, prefix) + + if os.path.isfile(filepath): + os.remove(filepath) + + with open(filepath, "w") as cache_file: + cache_file.write(json.dumps(data)) def create_from_entry(self, entry): - filepath = self.get_cache_filepath(entry['id']) + filepath = self.get_cache_filepath(entry["id"]) if os.path.isfile(filepath): os.remove(filepath) - with open(filepath, 'w') as cache_file: + with open(filepath, "w") as cache_file: cache_file.write(json.dumps(entry)) def create_from_new_entry_response(self, response_data): cached_entry = response_data.copy() - cached_entry['project'] = {'id': cached_entry['projectId']} - del cached_entry['projectId'] + cached_entry["project"] = {"id": cached_entry["projectId"]} + del cached_entry["projectId"] - if 'taskId' in cached_entry: - cached_entry['task'] = {'id': cached_entry['taskId']} + if "taskId" in cached_entry: + cached_entry["task"] = {"id": cached_entry["taskId"]} else: - cached_entry['task'] = None - del cached_entry['taskId'] + cached_entry["task"] = None + del cached_entry["taskId"] - cached_entry['tags'] = None - del cached_entry['tagIds'] + cached_entry["tags"] = None + del cached_entry["tagIds"] self.create_from_entry(cached_entry) @@ -93,89 +112,114 @@ def generate_update_entry(self, entry_id, comments=None, date=None, hours=None): # Change TimeEntrySummaryDto to work as UpdateTimeEntryRequest format (see Clockify API documentation) updated_entry = {} - updated_entry['id'] = cached_entry['id'] - updated_entry['description'] = cached_entry['description'] - updated_entry['start'] = cached_entry['timeInterval']['start'] - updated_entry['end'] = cached_entry['timeInterval']['end'] - updated_entry['projectId'] = cached_entry['project']['id'] - updated_entry['billable'] = cached_entry['billable'] - updated_entry['tagIds'] = [] + updated_entry["id"] = cached_entry["id"] + updated_entry["description"] = cached_entry["description"] + updated_entry["start"] = cached_entry["timeInterval"]["start"] + updated_entry["end"] = cached_entry["timeInterval"]["end"] + updated_entry["projectId"] = cached_entry["project"]["id"] + updated_entry["billable"] = cached_entry["billable"] + updated_entry["tagIds"] = [] - if 'task' in cached_entry and cached_entry['task']: - updated_entry['taskId'] = cached_entry['task']['id'] + if "task" in cached_entry and cached_entry["task"]: + updated_entry["taskId"] = cached_entry["task"]["id"] - if 'tags' in cached_entry and cached_entry['tags']: - for tag in cached_entry['tags']: - updated_entry['tagIds'].append(tag['id']) + if "tags" in cached_entry and cached_entry["tags"]: + for tag in cached_entry["tags"]: + updated_entry["tagIds"].append(tag["id"]) # Change comments, if necessary if comments: - updated_entry['description'] = comments + updated_entry["description"] = comments # Change UTC start date/time, if necessary if date: # Convert entry date to simple sting in local timezone - original_date_localized = self.utc_iso_8601_string_to_local_datetime(updated_entry['start']) - original_date = original_date_localized.strftime('%Y-%m-%d') + original_date_localized = self.utc_iso_8601_string_to_local_datetime( + updated_entry["start"] + ) + original_date = original_date_localized.strftime("%Y-%m-%d") if original_date != date: - updated_entry['start'] = self.local_date_string_to_utc_iso_8601(date) + updated_entry["start"] = self.local_date_string_to_utc_iso_8601(date) # Convert UTC start/time to localized datetime and use it to calculate ISO 8601 end date/time - start_datetime = dateutil.parser.parse(updated_entry['start']) - updated_entry['end'] = self.add_hours_to_localized_datetime_and_convert_to_iso_8601(start_datetime, hours) + start_datetime = dateutil.parser.parse(updated_entry["start"]) + updated_entry[ + "end" + ] = self.add_hours_to_localized_datetime_and_convert_to_iso_8601( + start_datetime, hours + ) return updated_entry - def get_cached_entry(self, identifier): - filepath = self.get_cache_filepath(identifier) + def get_cached_entry(self, identifier, prefix=None): + filepath = self.get_cache_filepath(identifier, prefix) if not os.path.isfile(filepath): return - with open(self.get_cache_filepath(identifier)) as json_file: + with open(filepath) as json_file: return json.load(json_file) class ClockifyApi(Iso8601DateConverter): - def __init__(self, apiKey, url=None): super(ClockifyApi, self).__init__() if not url: - url = 'https://api.clockify.me/api/' + url = "https://api.clockify.me/api/v1/" self.url = url self.key = apiKey - self.headers = {'Content-Type': 'application/json', 'X-Api-Key': self.key} + self.headers = {"Content-Type": "application/json", "X-Api-Key": self.key} self.cache = ClockifyEntryCacheManager() def post(self, url, data): return requests.post(url, data=json.dumps(data), headers=self.headers) - def set_workspace(self, id): - self.workspace = id + def set_workspace(self, workspace_id): + self.workspace = workspace_id def workspaces(self): url = "{}workspaces/".format(self.url) response = requests.get(url, headers=self.headers) return response.json() - def projects(self): + def projects(self, limit=None): url = "{}workspaces/{}/projects/".format(self.url, self.workspace) + + params = {} + + if limit is not None: + params["page-size"] = limit + + response = requests.get(url, headers=self.headers, params=params) + return response.json() + + def user(self): + url = "{}user/".format(self.url) response = requests.get(url, headers=self.headers) return response.json() def replace_datetime_time(self, date, time): - time_data = time.split(':') + time_data = time.split(":") hours = int(time_data[0]) minutes = int(time_data[1]) return date.replace(hour=hours, minute=minutes) - def create_entry(self, project, description, hours, date=None, start_time=None, billable=False, task=None): + def create_entry( + self, + project, + description, + hours, + date=None, + start_time=None, + billable=False, + task=None, + ): if not date: local_datetime = datetime.now() @@ -190,11 +234,13 @@ def create_entry(self, project, description, hours, date=None, start_time=None, end_date = isodate.datetime_isoformat(utc_end_datetime) else: if start_time: - date = date + ' ' + start_time + date = date + " " + start_time start_date = self.local_date_string_to_utc_iso_8601(date) localized_datetime = self.local_date_string_to_localized_datetime(date) - end_date = self.add_hours_to_localized_datetime_and_convert_to_iso_8601(localized_datetime, hours) + end_date = self.add_hours_to_localized_datetime_and_convert_to_iso_8601( + localized_datetime, hours + ) data = { "start": start_date, @@ -203,103 +249,56 @@ def create_entry(self, project, description, hours, date=None, start_time=None, "description": description, "projectId": project, "taskId": task, - "tagIds": [] + "tagIds": [], } - url = "{}workspaces/{}/timeEntries/".format(self.url, self.workspace) + url = "{}workspaces/{}/time-entries/".format(self.url, self.workspace) response = self.post(url, data) # Cache entry if entry was created - response_data = response.json() - - if 'projectId' in response_data: - self.cache.create_from_new_entry_response(response_data) - - return response_data - - # entry argument must be in UpdateTimeEntryRequest format (see Clockify API documentation) - def update_entry(self, entry, cached_entry=None): - url = "{}workspaces/{}/timeEntries/{}/".format(self.url, self.workspace, entry['id']) - response = requests.put(url, data=json.dumps(entry), headers=self.headers) - - if response.status_code == 200 and cached_entry: - if 'description' in entry: - cached_entry['description'] = entry['description'] - - if 'start' in entry: - cached_entry['timeInterval']['start'] = entry['start'] - - if 'end' in entry: - cached_entry['timeInterval']['end'] = entry['end'] - - iso_duration = self.cache.iso_duration_from_iso_8601_dates(cached_entry['timeInterval']['start'], cached_entry['timeInterval']['end']) - cached_entry['timeInterval']['duration'] = iso_duration - - if 'billable' in entry: - cached_entry['billable'] = entry['billable'] - - self.cache.create_from_entry(cached_entry) - - return response + return response.json() - def delete_entry(self, id): - url = "{}workspaces/{}/timeEntries/{}/".format(self.url, self.workspace, id) + def delete_entry(self, entry_id): + url = "{}workspaces/{}/time-entries/{}/".format(self.url, self.workspace, entry_id) return requests.delete(url, headers=self.headers) def entries(self, start=None, end=None, strict=False): - data = {'me': 'true'} + user = self.user() + + params = {} if start: - data['startDate'] = self.local_date_string_to_utc_iso_8601(start) + params["start"] = self.local_date_string_to_utc_iso_8601(start) if end: - data['endDate'] = self.local_date_string_to_utc_iso_8601(end) + params["end"] = self.local_date_string_to_utc_iso_8601(end) - data['userGroupIds'] = [] - data['userIds'] = [] - data['projectIds'] = [] - data['clientIds'] = [] - data['taskIds'] = [] - data['tagIds'] = [] - data['billable'] = 'BOTH' + url = "{}workspaces/{}/user/{}/time-entries".format( + self.url, self.workspace, user["id"] + ) + response = requests.get(url, params=params, headers=self.headers) - url = "{}workspaces/{}/reports/summary".format(self.url, self.workspace) - response = self.post(url, data) - - # work around API issue by manually culling entries out of date/time range response_data = response.json() - entries = [] - - for entry in response_data['timeEntries']: - if strict: - included = entry['timeInterval']['end'] <= data['endDate'] - else: - included = entry['timeInterval']['start'] <= data['endDate'] - - if included: - entries.append(entry) - - # Cache entry in case user wants to later update event - self.cache.create_from_entry(entry) - - return entries + return response_data - def get_project(self, id): - url = "{}workspaces/{}/projects/{}/".format(self.url, self.workspace, id) + def get_project(self, project_id): + url = "{}workspaces/{}/projects/{}/".format(self.url, self.workspace, project_id) response = requests.get(url, headers=self.headers) return response.json() - def project_tasks(self, id): - url = "{}workspaces/{}/projects/{}/tasks/".format(self.url, self.workspace, id) + def get_task(self, projectId, taskId): + url = "{}workspaces/{}/projects/{}/tasks/{}/".format( + self.url, self.workspace, projectId, taskId + ) response = requests.get(url, headers=self.headers) return response.json() - def get_task_project_id(self, id): - url = "{}workspaces/{}/projects/taskIds/".format(self.url, self.workspace) - data = {'ids': [id]} - tasks = self.post(url, data).json() + def get_entry(self, entry_id): + url = "{}workspaces/{}/time-entries/{}".format(self.url, self.workspace, entry_id) + response = requests.get(url, headers=self.headers) + return response.json() - if tasks != []: - return tasks[0]['projectId'] - else: - return None + def project_tasks(self, project_id): + url = "{}workspaces/{}/projects/{}/tasks/".format(self.url, self.workspace, project_id) + response = requests.get(url, headers=self.headers) + return response.json() diff --git a/clockifytool/app.py b/clockifytool/app.py index e0389bc..cf05a9b 100644 --- a/clockifytool/app.py +++ b/clockifytool/app.py @@ -1,5 +1,7 @@ from __future__ import print_function + import os + import yaml @@ -12,21 +14,27 @@ def load_config(): Returns: dict: Configuration information. """ - config_filename = '.cft.yml' + config_filename = ".cft.yml" # Attempt to load configuration file from user's home directory - config_path = os.path.join(os.path.expanduser('~'), config_filename) + config_path = os.path.join(os.path.expanduser("~"), config_filename) try: config = yaml.safe_load(open(config_path)) except IOError: - raise Exception('Unable to load ~/{}: does it exist (or is there a YAML error)?'.format(config_filename)) + raise Exception( + "Unable to load ~/{}: does it exist (or is there a YAML error)?".format( + config_filename + ) + ) # Add config filename to config - config['filename'] = config_filename + config["filename"] = config_filename # Verify Clockify API key has been set in the config file - if 'api key' not in config: - raise Exception('Please set Clockify API key as "api key" in {}.'.format(config_filename)) + if "api key" not in config: + raise Exception( + 'Please set Clockify API key as "api key" in {}.'.format(config_filename) + ) return config diff --git a/clockifytool/cli.py b/clockifytool/cli.py index 0eba72b..0832b73 100644 --- a/clockifytool/cli.py +++ b/clockifytool/cli.py @@ -1,8 +1,9 @@ import argparse -import dateutil import os import sys +import dateutil + sys.path.append(os.path.join(os.path.dirname(os.path.realpath(__file__)), os.pardir)) from clockifytool import helpers @@ -14,117 +15,143 @@ def preprocess_argv(): if len(argv): command_abbreviations = { - 'l': 'list', - 'n': 'new', - 'u': 'update', - 'd': 'delete', - 'w': 'workspaces', - 'p': 'projects', - 'pd': 'project', - 'td': 'task', - '-v': 'version', - '--version': 'version' + "l": "list", + "n": "new", + "d": "delete", + "w": "workspaces", + "p": "projects", + "pd": "project", + "td": "task", + "-v": "version", + "--version": "version", } if argv[0] in command_abbreviations: # Expand command abbreviation argv[0] = command_abbreviations[argv[0]] - elif argv[0][0:1] == '+': + elif argv[0][0:1] == "+": # "+" is shorthand for "new " - argv = ['new', argv[0][1:]] + argv[1:] + argv = ["new", argv[0][1:]] + argv[1:] elif helpers.resolve_period_abbreviation(argv[0]): # If time period given, not command, use as basis for list command - argv = ['list'] + argv[0:] + argv = ["list"] + argv[0:] else: # Default to "list" command - argv = ['list'] + argv = ["list"] return argv def arg_parser(): """Return ArgumentParser for this application.""" - parser = argparse.ArgumentParser(description='Clockify client.') - parser.add_argument('-v', '--version', help='show version and exit', action='store_true') + parser = argparse.ArgumentParser(description="Clockify client.") + parser.add_argument( + "-v", "--version", help="show version and exit", action="store_true" + ) - subparsers = parser.add_subparsers(dest='command') + subparsers = parser.add_subparsers(dest="command") # Parent parser for entry-specific commands entry_parser = argparse.ArgumentParser(add_help=False) - entry_parser.add_argument('-c', '--comments', metavar='comments: required for new time entries', action='store') - entry_parser.add_argument('-t', '--hours', metavar='hours spent: required for new time entries', action='store') - entry_parser.add_argument('-d', '--date', metavar='date', action='store', help='defaults to today') - entry_parser.add_argument('-b', '--billable', action='store_true') + entry_parser.add_argument( + "-c", + "--comments", + metavar="comments: required for new time entries", + action="store", + ) + entry_parser.add_argument( + "-t", + "--hours", + metavar="hours spent: required for new time entries", + action="store", + ) + entry_parser.add_argument( + "-d", "--date", metavar="date", action="store", help="defaults to today" + ) + entry_parser.add_argument("-b", "--billable", action="store_true") # New entry command - parser_new = subparsers.add_parser('new', help='Create new time entry', parents=[entry_parser]) - parser_new.add_argument('id', metavar='project ID', help='ID of project or task: required') - parser_new.add_argument('-s', '--start', metavar='start time', action='store') - parser_new.set_defaults(func='new_entry') - - # Update entry command - parser_update = subparsers.add_parser('update', help='Update time entry', parents=[entry_parser]) - parser_update.add_argument('id', metavar='entry ID', help='ID of time entry: required') - parser_update.add_argument('-a', '--append', metavar='append: append text to comments', action='store') - parser_update.add_argument('-u', '--unbillable', action='store_true') - parser_update.set_defaults(func='update_entry') + parser_new = subparsers.add_parser( + "new", help="Create new time entry", parents=[entry_parser] + ) + parser_new.add_argument( + "id", metavar="project ID", help="ID of project or task: required" + ) + parser_new.add_argument("-s", "--start", metavar="start time", action="store") + parser_new.set_defaults(func="new_entry") # List command - parser_list = subparsers.add_parser('list', help='List time entries', epilog=helpers.describe_periods()) - parser_list.add_argument('period', nargs='?', metavar='period', help='time period: optional, overrides -s and -e') - parser_list.add_argument('-s', '--start', metavar='start date', action='store') - parser_list.add_argument('-e', '--end', metavar='end date', action='store') - parser_list.add_argument('--strict', action='store_true') - parser_list.add_argument('-v', '--verbose', action='store_true') - parser_list.set_defaults(func='list_entries') + parser_list = subparsers.add_parser( + "list", help="List time entries", epilog=helpers.describe_periods() + ) + parser_list.add_argument( + "period", + nargs="?", + metavar="period", + help="time period: optional, overrides -s and -e", + ) + parser_list.add_argument("-s", "--start", metavar="start date", action="store") + parser_list.add_argument("-e", "--end", metavar="end date", action="store") + parser_list.add_argument("--strict", action="store_true") + parser_list.add_argument("-v", "--verbose", action="store_true") + parser_list.set_defaults(func="list_entries") # Delete command - parser_delete = subparsers.add_parser('delete', help='Delete time entry') - parser_delete.add_argument('id', metavar='time entry ID', help='ID of time entry: required') - parser_delete.set_defaults(func='delete_entry') + parser_delete = subparsers.add_parser("delete", help="Delete time entry") + parser_delete.add_argument( + "id", metavar="time entry ID", help="ID of time entry: required" + ) + parser_delete.set_defaults(func="delete_entry") # Workspaces command - parser_workspaces = subparsers.add_parser('workspaces', help='List workspaces') - parser_workspaces.set_defaults(func='list_workspaces') + parser_workspaces = subparsers.add_parser("workspaces", help="List workspaces") + parser_workspaces.set_defaults(func="list_workspaces") # Projects command - parser_projects = subparsers.add_parser('projects', help='List projects') - parser_projects.set_defaults(func='list_projects') + parser_projects = subparsers.add_parser("projects", help="List projects") + parser_projects.add_argument( + "-l", "--limit", metavar="number of projects per page", action="store" + ) + parser_projects.set_defaults(func="list_projects") # Project details command - parser_project = subparsers.add_parser('project', help='Project details') - parser_project.add_argument('id', metavar='project ID', help='ID of project: required') - parser_project.set_defaults(func='project_details') + parser_project = subparsers.add_parser("project", help="Project details") + parser_project.add_argument( + "id", metavar="project ID", help="ID of project: required" + ) + parser_project.set_defaults(func="project_details") # Task details command - parser_task = subparsers.add_parser('task', help='Task details') - parser_task.add_argument('id', metavar='task ID', help='ID of task: required') - parser_task.set_defaults(func='task_details') + parser_task = subparsers.add_parser("task", help="Task details") + parser_task.add_argument("id", metavar="task ID", help="ID of task: required") + parser_task.set_defaults(func="task_details") # Cache command - parser_cache = subparsers.add_parser('cache', help='Cache status/management') - parser_cache.add_argument('-f', '--flush', action='store_true') - parser_cache.set_defaults(func='cache_statistics') + parser_cache = subparsers.add_parser("cache", help="Cache status/management") + parser_cache.add_argument("-f", "--flush", action="store_true") + parser_cache.set_defaults(func="cache_statistics") # Version commmand - parser_version = subparsers.add_parser('version', help='Display version') + subparsers.add_parser("version", help="Display version") return parser def validate_args(parser, args, config): # Normalize and validate period - if 'period' in args and args.period: + if "period" in args and args.period: args.period = helpers.resolve_period_abbreviation(args.period) if not args.period: - parser.error('Invalid period.') + parser.error("Invalid period.") # Normalize and validate project/entry ID - if 'id' in args and args.id: - if args.command == 'new': + if "id" in args and args.id: + if args.command == "new": # Allow use of preset comments and/or hours - default_comments = helpers.template_field(args.id, 'comments', config['projects']) - default_hours = helpers.template_field(args.id, 'hours', config['projects']) + default_comments = helpers.template_field( + args.id, "comments", config["projects"] + ) + default_hours = helpers.template_field(args.id, "hours", config["projects"]) if default_comments and not args.comments: args.comments = default_comments @@ -133,28 +160,30 @@ def validate_args(parser, args, config): args.hours = default_hours # Resolve preset name to ID - args.id = helpers.resolve_project_alias(args.id, config['projects']) + args.id = helpers.resolve_project_alias(args.id, config["projects"]) # Resolve dates, if set - if 'date' in args and args.date: + if "date" in args and args.date: args.date = resolve_and_validate_date_value(args.date, parser) - if 'start' in args and args.start: + if "start" in args and args.start: args.start = resolve_and_validate_date_value(args.start, parser) - if 'end' in args and args.end: + if "end" in args and args.end: args.end = resolve_and_validate_date_value(args.end, parser) # Don't allow both billable and unbillable options to be used at the same time - if ('billable' in args and args.billable) and ('unbillable' in args and args.unbillable): + if ("billable" in args and args.billable) and ( + "unbillable" in args and args.unbillable + ): parser.error("Both --billable and --unbillable can't be used at the same time.") # Sanity-check hours, if set - if 'hours' in args and args.hours: + if "hours" in args and args.hours: try: float(args.hours) except ValueError: - parser.error('Invalid hours value.') + parser.error("Invalid hours value.") return args @@ -167,6 +196,6 @@ def resolve_and_validate_date_value(value, parser): try: dateutil.parser.parse(value) except ValueError: - parser.error('{} is not a valid date.'.format(value)) + parser.error("{} is not a valid date.".format(value)) return value diff --git a/clockifytool/commands.py b/clockifytool/commands.py index 2e5307d..47d7204 100644 --- a/clockifytool/commands.py +++ b/clockifytool/commands.py @@ -1,8 +1,9 @@ from __future__ import print_function -from datetime import date -import shutil + import os +import shutil import sys +from datetime import date sys.path.append(os.path.join(os.path.dirname(os.path.realpath(__file__)), os.pardir)) @@ -11,7 +12,7 @@ def list_entries(args, config, app_data): today_raw = date.today() - today = today_raw.strftime('%Y-%m-%d') + today = today_raw.strftime("%Y-%m-%d") if args.start or args.end: # Handle --start and --end @@ -40,172 +41,146 @@ def list_entries(args, config, app_data): # Periods will override --from and --to if args.period and helpers.resolve_period(args.period): period = helpers.resolve_period(args.period) - from_date = period['start'] - to_date = period['end'] + from_date = period["start"] + to_date = period["end"] - helpers.time_entry_list(from_date, to_date, app_data['clockify'], args.strict, args.verbose) + helpers.time_entry_list( + from_date, to_date, app_data["clockify"], args.strict, args.verbose + ) def new_entry(args, config, app_data): - if 'hours' not in args or not args.hours: - print('Specify hours.') + if "hours" not in args or not args.hours: + print("Specify hours.") return if float(args.hours) <= 0: - print('Hours value must be positive.') + print("Hours value must be positive.") return # Check if ID indicates a task rather than project task_id = None - project_id = app_data['clockify'].get_task_project_id(args.id) + project_id = None + + task = app_data["clockify"].cache.get_cached_entry(args.id, "task") + + # If task hasn't been cached, cache all project tasks + if task is None: + helpers.cache_workspace_tasks(app_data["clockify"]) + task = app_data["clockify"].cache.get_cached_entry(args.id, "task") + + if task is not None: + project_id = task["projectId"] if project_id is None: project_id = args.id else: task_id = args.id - entry = app_data['clockify'].create_entry(project_id, args.comments, args.hours, args.date, args.start, args.billable, task=task_id) - - if 'message' in entry and 'code' in entry: - print(entry['message']) + # Set start time to default if date's different than current + today_raw = date.today() + today = today_raw.strftime("%Y-%m-%d") + + if args.date is not None and args.date != today and args.start is None: + args.start = "08:00:00" + + entry = app_data["clockify"].create_entry( + project_id, + args.comments, + args.hours, + args.date, + args.start, + args.billable, + task=task_id, + ) + + if "message" in entry and "code" in entry: + print(entry["message"]) return - print(helpers.entry_bullet_point(app_data['clockify'], entry)) + print(helpers.entry_bullet_point(app_data["clockify"], entry)) print("Time entry created.") -def update_entry(args, config, app_data): - changed = False - - # Need to use cached time entry data because API doesn't support getting time entry data by ID - cached_entry = app_data['clockify'].cache.get_cached_entry(args.id) - - if not cached_entry: - print('Time entry does not exist or is not cached.') - return - - # Establish entry hours - cached_hours = app_data['clockify'].cache.iso_duration_to_hours(cached_entry['timeInterval']['duration']) - updated_hours = cached_hours - - # Adjust hours, if necessary - if args.hours and (helpers.contains_calculation(args.hours) or cached_hours != float(args.hours)): - changed = True - updated_hours = helpers.handle_hours_calculation_value(float(cached_hours), args.hours) - print("Changing hours from {} to: {}".format(str(cached_hours), str(updated_hours))) - - updated_entry = app_data['clockify'].cache.generate_update_entry(args.id, comments=args.comments, date=args.date, hours=updated_hours) - - # Update description, if necessary - if args.comments and args.comments != cached_entry['description']: - changed = True - print("Changing comments to: {}".format(args.comments)) - - # Append to description, if necesary - if args.append: - changed = True - updated_entry['description'] += ' ' + args.append - print("Appended to comments: {}".format(args.append)) - - # Update start date, if necessary - if args.date: - cached_date_local = app_data['clockify'].utc_iso_8601_string_to_local_datetime(cached_entry['timeInterval']['start']) - update_date_local = app_data['clockify'].utc_iso_8601_string_to_local_datetime(updated_entry['start']) - - if cached_date_local.date() != update_date_local.date(): - changed = True - print("Changing date to {}".format(args.date)) - - # Update billable status, if necessary - if args.billable and not cached_entry['billable']: - updated_entry['billable'] = True - changed = True - print('Setting to billable.') - - if args.unbillable and cached_entry['billable']: - updated_entry['billable'] = False - changed = True - print('Setting to unbillable.') - - if changed: - # Perform update via API - response = app_data['clockify'].update_entry(updated_entry, cached_entry) - - if response.status_code == 200: - print(helpers.entry_bullet_point(app_data['clockify'], cached_entry)) - print('Time entry updated.') - else: - response_data = response.json() - print('Unexpected response status code: ' + str(response.status_code)) - if 'message' in response_data: - print('Message: ' + response_data['message']) - else: - print('No update as no change requested.') - - def delete_entry(args, config, app_data): - response = app_data['clockify'].delete_entry(args.id) + response = app_data["clockify"].delete_entry(args.id) if response.status_code == 204: - cache_filepath = app_data['clockify'].cache.get_cache_filepath(args.id) + cache_filepath = app_data["clockify"].cache.get_cache_filepath(args.id) if os.path.isfile(cache_filepath): os.remove(cache_filepath) - print('Time entry deleted.') + print("Time entry deleted.") else: - print('Time entry not found.') + print("Time entry not found.") def list_workspaces(args, config, app_data): - for workspace in app_data['clockify'].workspaces(): - print('* {} [{}]'.format(workspace['name'], workspace['id'])) + for workspace in app_data["clockify"].workspaces(): + print("* {} [{}]".format(workspace["name"], workspace["id"])) def list_projects(args, config, app_data): project_names = [] project_data = {} - for project in app_data['clockify'].projects(): - project_names.append(project['name']) - project_data[(project['name'])] = project + for project in app_data["clockify"].projects(args.limit): + project_names.append(project["name"]) + project_data[(project["name"])] = project project_names.sort() for project in project_names: - print('* {} [{}]'.format(project.encode('utf-8'), project_data[project]['id'])) + print("* {} [{}]".format(project.encode("utf-8"), project_data[project]["id"])) def cache_statistics(args, config, app_data): - cache_dir = os.path.join(app_data['clockify'].cache.get_cache_directory()) + cache_dir = os.path.join(app_data["clockify"].cache.get_cache_directory()) - cache_files = [f for f in os.listdir(cache_dir) if os.path.isfile(os.path.join(cache_dir, f))] + cache_files = [ + f for f in os.listdir(cache_dir) if os.path.isfile(os.path.join(cache_dir, f)) + ] - if (len(cache_files)): - print('Cached time entries: {}'.format(str(len(cache_files)))) + if len(cache_files): + print("Cached time entries: {}".format(str(len(cache_files)))) - if 'flush' in args and args.flush: - print('Cache flushed.') + if "flush" in args and args.flush: + print("Cache flushed.") shutil.rmtree(cache_dir) else: - print('Cache is empty.') + print("Cache is empty.") def project_details(args, config, app_data): - project_data = app_data['clockify'].get_project(args.id) + project_data = app_data["clockify"].get_project(args.id) - print("Name: {}".format(project_data['name'])) + if "message" in project_data: + print(project_data["message"]) + return + + print("Name: {}".format(project_data["name"])) - if 'clientName' in project_data: - print("Client: {}".format(project_data['clientName'])) + if "clientName" in project_data: + print("Client: {}".format(project_data["clientName"])) print() print("Tasks:") - for project in app_data['clockify'].project_tasks(args.id): - print('* {} [{}]'.format(project['name'].encode('utf-8'), project['id'])) + for project in app_data["clockify"].project_tasks(args.id): + print("* {} [{}]".format(project["name"].encode("utf-8"), project["id"])) def task_details(args, config, app_data): - project_id = app_data['clockify'].get_task_project_id(args.id) - print("Project ID: {}".format(project_id)) + task = app_data["clockify"].cache.get_cached_entry(args.id, "task") + + # If task hasn't been cached, cache all project tasks + if task is None: + helpers.cache_workspace_tasks(app_data["clockify"]) + task = app_data["clockify"].cache.get_cached_entry(args.id, "task") + + if task is None: + print("Task not found.") + else: + print(str(task)) + print("Project ID: {}".format(task["projectId"])) diff --git a/clockifytool/helpers.py b/clockifytool/helpers.py index 1cb2195..696df1e 100644 --- a/clockifytool/helpers.py +++ b/clockifytool/helpers.py @@ -1,40 +1,49 @@ -from __future__ import print_function -from __future__ import unicode_literals -from builtins import str +from __future__ import print_function, unicode_literals + import calendar import collections +from builtins import str from datetime import date, datetime, timedelta -import dateutil.parser +import dateutil.parser PERIODS = collections.OrderedDict() -PERIODS['y'] = {'name': 'yesterday', 'description': 'day before today'} -PERIODS['dby'] = {'name': 'daybeforeyesterday', 'description': 'day before yesterday'} -PERIODS['lw'] = {'name': 'lastweek', 'description': 'last work week (Monday to Friday)'} -PERIODS['cw'] = {'name': 'currentweek', 'description': 'current work week (Monday to Friday)'} -PERIODS['flw'] = {'name': 'fulllastweek', 'description': 'last full week (Sunday to Saturday)'} -PERIODS['fcw'] = {'name': 'fullcurrentweek', 'description': 'current full week (Sunday to Saturday)'} -PERIODS['lm'] = {'name': 'lastmonth', 'description': 'last month'} -PERIODS['cm'] = {'name': 'currentmonth', 'description': 'current month'} -PERIODS['ly'] = {'name': 'lastyear', 'description': 'last year'} -PERIODS['cy'] = {'name': 'currentyear', 'description': 'current year'} -PERIODS['mon'] = {'name': 'monday', 'description': 'Monday'} -PERIODS['tue'] = {'name': 'tuesday', 'description': 'Tuesday'} -PERIODS['wed'] = {'name': 'wednesday', 'description': 'Wednesday'} -PERIODS['thu'] = {'name': 'thursday', 'description': 'Thursday'} -PERIODS['fri'] = {'name': 'friday', 'description': 'Friday'} -PERIODS['sat'] = {'name': 'saturday', 'description': 'Saturday'} -PERIODS['sun'] = {'name': 'sunday', 'description': 'Sunday'} -PERIODS['lmon'] = {'name': 'lastmonday', 'description': 'Last Monday'} -PERIODS['ltue'] = {'name': 'lasttuesday', 'description': 'Last Tuesday'} -PERIODS['lwed'] = {'name': 'lastwednesday', 'description': 'Last Wednesday'} -PERIODS['lthu'] = {'name': 'lastthursday', 'description': 'Last Thursday'} -PERIODS['lfri'] = {'name': 'lastfriday', 'description': 'Last Friday'} -PERIODS['lsat'] = {'name': 'lastsaturday', 'description': 'Last Saturday'} -PERIODS['lsun'] = {'name': 'lastsunday', 'description': 'Last Sunday'} -PERIODS['cp'] = {'name': 'currentpayperiod', 'description': 'current pay period'} -PERIODS['pp'] = {'name': 'previouspayperiod', 'description': 'previous pay period'} +PERIODS["y"] = {"name": "yesterday", "description": "day before today"} +PERIODS["dby"] = {"name": "daybeforeyesterday", "description": "day before yesterday"} +PERIODS["lw"] = {"name": "lastweek", "description": "last work week (Monday to Friday)"} +PERIODS["cw"] = { + "name": "currentweek", + "description": "current work week (Monday to Friday)", +} +PERIODS["flw"] = { + "name": "fulllastweek", + "description": "last full week (Sunday to Saturday)", +} +PERIODS["fcw"] = { + "name": "fullcurrentweek", + "description": "current full week (Sunday to Saturday)", +} +PERIODS["lm"] = {"name": "lastmonth", "description": "last month"} +PERIODS["cm"] = {"name": "currentmonth", "description": "current month"} +PERIODS["ly"] = {"name": "lastyear", "description": "last year"} +PERIODS["cy"] = {"name": "currentyear", "description": "current year"} +PERIODS["mon"] = {"name": "monday", "description": "Monday"} +PERIODS["tue"] = {"name": "tuesday", "description": "Tuesday"} +PERIODS["wed"] = {"name": "wednesday", "description": "Wednesday"} +PERIODS["thu"] = {"name": "thursday", "description": "Thursday"} +PERIODS["fri"] = {"name": "friday", "description": "Friday"} +PERIODS["sat"] = {"name": "saturday", "description": "Saturday"} +PERIODS["sun"] = {"name": "sunday", "description": "Sunday"} +PERIODS["lmon"] = {"name": "lastmonday", "description": "Last Monday"} +PERIODS["ltue"] = {"name": "lasttuesday", "description": "Last Tuesday"} +PERIODS["lwed"] = {"name": "lastwednesday", "description": "Last Wednesday"} +PERIODS["lthu"] = {"name": "lastthursday", "description": "Last Thursday"} +PERIODS["lfri"] = {"name": "lastfriday", "description": "Last Friday"} +PERIODS["lsat"] = {"name": "lastsaturday", "description": "Last Saturday"} +PERIODS["lsun"] = {"name": "lastsunday", "description": "Last Sunday"} +PERIODS["cp"] = {"name": "currentpayperiod", "description": "current pay period"} +PERIODS["pp"] = {"name": "previouspayperiod", "description": "previous pay period"} # Artefactual's pay period details @@ -43,17 +52,47 @@ def time_entry_list(from_date, to_date, clockify, strict=False, verbose=False): - from_date_description = '{} ({})'.format(from_date, date_string_to_weekday_string(from_date)) - to_date_description = '{} ({})'.format(to_date, date_string_to_weekday_string(to_date)) + from_date_description = "{} ({})".format( + from_date, date_string_to_weekday_string(from_date) + ) + to_date_description = "{} ({})".format( + to_date, date_string_to_weekday_string(to_date) + ) if from_date == to_date: print("Fetching time entries from {}...".format(from_date_description)) else: - print("Fetching time entries from {} to {}...".format(from_date_description, to_date_description)) + print( + "Fetching time entries from {} to {}...".format( + from_date_description, to_date_description + ) + ) print() # Get yesterday's time entries - time_entries = clockify.entries(start=from_date + 'T00:00:00', end=to_date + 'T23:59:59', strict=strict) + time_entries = clockify.entries( + start=from_date + "T00:00:00", end=to_date + "T23:59:59", strict=strict + ) + + # Augment data + for entry in time_entries: + if entry["projectId"] is not None: + project = clockify.cache.get_cached_entry(entry["projectId"]) + + if project is None: + project = clockify.get_project(entry["projectId"]) + clockify.cache.create(project) + + entry["project"] = {"name": project["name"], "id": entry["projectId"]} + + if entry["taskId"] is not None: + task = clockify.cache.get_cached_entry(entry["taskId"]) + + if task is None: + task = clockify.get_task(entry["projectId"], entry["taskId"]) + clockify.cache.create(task, task["id"], "task") + + entry["task"] = {"name": task["name"], "id": entry["taskId"]} if time_entries: sum = 0 @@ -63,7 +102,9 @@ def time_entry_list(from_date, to_date, clockify, strict=False, verbose=False): for entry in time_entries: report += entry_bullet_point(clockify, entry, verbose) - sum += clockify.cache.iso_duration_to_hours(entry['timeInterval']['duration']) + sum += clockify.cache.iso_duration_to_hours( + entry["timeInterval"]["duration"] + ) report += "\n" + str(sum) + " hours.\n" else: @@ -73,45 +114,56 @@ def time_entry_list(from_date, to_date, clockify, strict=False, verbose=False): def entry_bullet_point(clockify, entry, verbose=False): - item = '* ' + item = "* " if verbose: - local_date_and_time = clockify.cache.utc_iso_8601_string_to_local_datatime_string(entry['timeInterval']['start']) - item += '{} - '.format(str(local_date_and_time)) + local_date_and_time = clockify.cache.utc_iso_8601_string_to_local_datatime_string( + entry["timeInterval"]["start"] + ) + item += "{} - ".format(str(local_date_and_time)) - item += '{}'.format(str(entry['description'])) + item += "{}".format(str(entry["description"])) - if 'project' in entry and 'name' in entry['project']: - if 'task' in entry and entry['task'] is not None and 'name' in entry['task']: + if "project" in entry and "name" in entry["project"]: + if "task" in entry and entry["task"] is not None and "name" in entry["task"]: if verbose: - item = item + ' ({}: {} / task: {}: {})'.format(entry['project']['name'], entry['project']['id'], entry['task']['name'], entry['task']['id']) + item = item + " ({}: {} / task: {}: {})".format( + entry["project"]["name"], + entry["project"]["id"], + entry["task"]["name"], + entry["task"]["id"], + ) else: - item = item + ' (task: {}:{})'.format(entry['task']['name'], entry['task']['id']) + item = item + " (task: {}:{})".format( + entry["task"]["name"], entry["task"]["id"] + ) else: - item = item + ' ({}: {})'.format(entry['project']['name'], entry['project']['id']) + item = item + " ({}: {})".format( + entry["project"]["name"], entry["project"]["id"] + ) - hours = clockify.cache.iso_duration_to_hours(entry['timeInterval']['duration']) - item = item + ' [{} hours: {}'.format(hours, entry['id']) + hours = clockify.cache.iso_duration_to_hours(entry["timeInterval"]["duration"]) + item = item + " [{} hours: {}".format(hours, entry["id"]) - if entry['billable']: - item = item + ', billable' + if entry["billable"]: + item = item + ", billable" - item = item + ']' + item = item + "]" return item + "\n" def contains_calculation(value): - return value[:1] == '+' or value[:1] == '-' + return value[:1] == "+" or value[:1] == "-" def handle_date_calculation_value(date_value): - if date_value == 'today': - date_value = '+0' + if date_value == "today": + date_value = "+0" if contains_calculation(date_value): date_value_raw = date.today() + timedelta(int(date_value)) - date_value = date_value_raw.strftime('%Y-%m-%d') + date_value = date_value_raw.strftime("%Y-%m-%d") return date_value @@ -126,11 +178,13 @@ def handle_hours_calculation_value(current_hours, new_value_or_calculation): def date_string_to_weekday_string(date_string): - return dateutil.parser.parse(date_string).strftime('%A') + return dateutil.parser.parse(date_string).strftime("%A") def weekday_of_week(day_of_week, weeks_previous=0): - days_ahead_of_weekday_last_week = date.today().weekday() + (weeks_previous * 7) - day_of_week + days_ahead_of_weekday_last_week = ( + date.today().weekday() + (weeks_previous * 7) - day_of_week + ) last_weekday = datetime.now() - timedelta(days=days_ahead_of_weekday_last_week) return last_weekday.strftime("%Y-%m-%d") @@ -143,137 +197,137 @@ def resolve_period_abbreviation(period): period = period.lower() if period in PERIODS: - return PERIODS[period]['name'] + return PERIODS[period]["name"] - if period in {abbr: item.get('name') for abbr, item in PERIODS.items()}.values(): + if period in {abbr: item.get("name") for abbr, item in PERIODS.items()}.values(): return period return None def resolve_period(period): - if period == 'yesterday': - yesterday = handle_date_calculation_value('-1') - return {'start': yesterday, 'end': yesterday} + if period == "yesterday": + yesterday = handle_date_calculation_value("-1") + return {"start": yesterday, "end": yesterday} - if period == 'daybeforeyesterday': - yesterday = handle_date_calculation_value('-2') - return {'start': yesterday, 'end': yesterday} + if period == "daybeforeyesterday": + yesterday = handle_date_calculation_value("-2") + return {"start": yesterday, "end": yesterday} - if period == 'lastweek': + if period == "lastweek": start_date = weekday_last_week(0) # last Monday end_date = weekday_last_week(4) # last Friday - if period == 'currentweek': + if period == "currentweek": start_date = weekday_of_week(0) # this Monday end_date = weekday_of_week(4) # this Friday - if period == 'fulllastweek': + if period == "fulllastweek": start_date = weekday_of_week(6, 2) # last Sunday end_date = weekday_of_week(5, 1) # last Saturday - if period == 'fullcurrentweek': + if period == "fullcurrentweek": start_date = weekday_last_week(6) # this Sunday end_date = weekday_of_week(5) # this Saturday - if period == 'monday': + if period == "monday": start_date = weekday_of_week(0) # this Monday end_date = start_date - if period == 'tuesday': + if period == "tuesday": start_date = weekday_of_week(1) # this Tuesday end_date = start_date - if period == 'wednesday': + if period == "wednesday": start_date = weekday_of_week(2) # this Wednesday end_date = start_date - if period == 'thursday': + if period == "thursday": start_date = weekday_of_week(3) # this Thursday end_date = start_date - if period == 'friday': + if period == "friday": start_date = weekday_of_week(4) # this Friday end_date = start_date - if period == 'saturday': + if period == "saturday": start_date = weekday_of_week(5) # this Saturday end_date = start_date - if period == 'sunday': + if period == "sunday": start_date = weekday_of_week(6) # last Sunday end_date = start_date - if period == 'lastmonday': + if period == "lastmonday": start_date = weekday_last_week(0) # last Monday end_date = start_date - if period == 'lasttuesday': + if period == "lasttuesday": start_date = weekday_last_week(1) # last Tuesday end_date = start_date - if period == 'lastwednesday': + if period == "lastwednesday": start_date = weekday_last_week(2) # last Wednesday end_date = start_date - if period == 'lastthursday': + if period == "lastthursday": start_date = weekday_last_week(3) # last Thursday end_date = start_date - if period == 'lastfriday': + if period == "lastfriday": start_date = weekday_last_week(4) # last Friday end_date = start_date - if period == 'lastsaturday': + if period == "lastsaturday": start_date = weekday_last_week(5) # last Saturday end_date = start_date - if period == 'lastsunday': + if period == "lastsunday": start_date = weekday_last_week(6) # Sunday before last Sunday end_date = start_date today = date.today() - if period == 'lastmonth': + if period == "lastmonth": first = today.replace(day=1) last_month = first - timedelta(days=1) last_year_and_month = last_month.strftime("%Y-%m") - start_date = last_year_and_month + '-01' - end_date = last_year_and_month + '-' + str(last_month.day) + start_date = last_year_and_month + "-01" + end_date = last_year_and_month + "-" + str(last_month.day) - if period == 'currentmonth': - year_and_month = datetime.today().strftime('%Y-%m') - start_date = year_and_month + '-01' + if period == "currentmonth": + year_and_month = datetime.today().strftime("%Y-%m") + start_date = year_and_month + "-01" _, days_in_month = calendar.monthrange(today.year, today.month) - end_date = year_and_month + '-' + str(days_in_month) + end_date = year_and_month + "-" + str(days_in_month) - if period == 'lastyear': - start_date = '{}-01-01'.format(str(today.year - 1)) - end_date = '{}-12-31'.format(str(today.year - 1)) + if period == "lastyear": + start_date = "{}-01-01".format(str(today.year - 1)) + end_date = "{}-12-31".format(str(today.year - 1)) - if period == 'currentyear': - start_date = '{}-01-01'.format(str(today.year)) - end_date = '{}-12-31'.format(str(today.year)) + if period == "currentyear": + start_date = "{}-01-01".format(str(today.year)) + end_date = "{}-12-31".format(str(today.year)) # Payroll periods - if period.endswith('payperiod'): + if period.endswith("payperiod"): past_days = (today - PERIOD_FIRST_DAY).days % PERIOD_DAYS - if period == 'currentpayperiod': + if period == "currentpayperiod": start_date = today - timedelta(days=past_days) end_date = today + timedelta(days=PERIOD_DAYS - past_days - 1) - elif period == 'previouspayperiod': + elif period == "previouspayperiod": end_date = today - timedelta(days=past_days + 1) start_date = end_date - timedelta(days=PERIOD_DAYS - 1) return { - 'start': start_date.strftime("%Y-%m-%d"), - 'end': end_date.strftime("%Y-%m-%d") + "start": start_date.strftime("%Y-%m-%d"), + "end": end_date.strftime("%Y-%m-%d"), } - return {'start': start_date, 'end': end_date} + return {"start": start_date, "end": end_date} def resolve_project_template(project_name, templates): @@ -288,7 +342,7 @@ def template_field(issue_name, field, templates): def resolve_project_alias(issue_id, templates): - resolved_id = template_field(issue_id, 'id', templates) + resolved_id = template_field(issue_id, "id", templates) if resolved_id: return resolve_project_alias(resolved_id, templates) @@ -297,15 +351,34 @@ def resolve_project_alias(issue_id, templates): def describe_periods(): - description = 'Available periods: ' + description = "Available periods: " first = True for abbreviation, period in PERIODS.items(): if not first: - description += ', ' + description += ", " - description += '"{}" ("{}"): {}'.format(period['name'], abbreviation, period['description']) + description += '"{}" ("{}"): {}'.format( + period["name"], abbreviation, period["description"] + ) first = False return description + + +def cache_workspace_tasks(clockify): + print("Caching project tasks (this can take awhile)...") + + # Cycle through each project in the workspace + for project in clockify.projects(limit=1000): + # Get cached project tasks (or get project tasks via API) + project_tasks = clockify.cache.get_cached_entry(project["id"], "project-tasks") + + if project_tasks is None: + project_tasks = clockify.project_tasks(project["id"]) + clockify.cache.create(project_tasks, project["id"], "project-tasks") + + # Cache project's tasks + for task in project_tasks: + clockify.cache.create(task, task["id"], "task") diff --git a/requirements.txt b/requirements.txt index cb83110..5603c37 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1 @@ -future -isodate -python-dateutil -PyYAML -requests -tzlocal +-r requirements/base.txt diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000..b63619b --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,6 @@ +future +isodate +python-dateutil +PyYAML +requests +tzlocal==2.1 diff --git a/requirements/test.txt b/requirements/test.txt new file mode 100644 index 0000000..719a982 --- /dev/null +++ b/requirements/test.txt @@ -0,0 +1,4 @@ +-r base.txt + +flake8==5.0.4 +flake8-builtins==2.2.0 diff --git a/setup.py b/setup.py index f652242..16e9e19 100644 --- a/setup.py +++ b/setup.py @@ -1,32 +1,26 @@ -import os from setuptools import setup # Get requirements -with open('requirements.txt') as f: +with open("requirements/base.txt") as f: requirements = f.read().splitlines() # Get text of the README file -with open('README.md') as f: +with open("README.md") as f: README = f.read() setup( - name='clockifytool', - packages=['clockifytool'], - version='0.0.3', - license='MIT', - description='Tool to list, create, and delete time entries in Clockify', - author='Mike Cantelon', - author_email='mcantelon@gmail.com', - - long_description=README, - long_description_content_type='text/markdown', - - url='https://github.com/artefactual-labs/clockify-tool', - - keywords=['clockify'], - - install_requires=requirements, - - scripts=['bin/cft'], + name="clockifytool", + packages=["clockifytool"], + version="0.0.3", + license="MIT", + description="Tool to list, create, and delete time entries in Clockify", + author="Mike Cantelon", + author_email="mcantelon@gmail.com", + long_description=README, + long_description_content_type="text/markdown", + url="https://github.com/artefactual-labs/clockify-tool", + keywords=["clockify"], + install_requires=requirements, + scripts=["bin/cft"], ) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..719bdc9 --- /dev/null +++ b/tox.ini @@ -0,0 +1,37 @@ +[tox] +envlist = linting + +[testenv] +deps = -r{toxinidir}/requirements/test.txt + +[testenv:linting] +basepython = python3 +deps = pre-commit +commands = pre-commit run --all-files --show-diff-on-failure + +[flake8] +exclude = .git, .tox, __pycache__, old, build, dist, txt, .ini +application-import-names = flake8 + +select = A,B,C,E,F,W,T4,B9 +ignore = + A003, + # Class attribute is shadowing a Python builtin. + E402, + # Module level import not at top of file. + E501, + # Lines are too long. + W503, + # Line break before binary operator. + E203 + # Whitespace before ':'. + +import-order-style = pep8 + +[isort] +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +ensure_newline_before_comments = True +line_length = 88