diff --git a/.gitignore b/.gitignore index 53605b7..da8c776 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ venv .pytest_cache *.egg-info .DS_Store +.idea diff --git a/README.md b/README.md index 2215a00..dd2807f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,26 @@ Install this tool using `pip`: ## Usage +For listing the time entries in the last 24 hours, run: + + $ tgl entries list + Time Entries + + At Description Start Stop Duration Tags + ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + 2023-01-28 toggl-track: list time entries 04:33 AM + 2023-01-27 ESF: telemetry 05:23 PM 07:19 PM an hour type:goal + 2023-01-27 ESF: telemetry 03:28 PM 05:00 PM an hour type:goal + 2023-01-27 Community: Fix parsing error client port is blank and adjust for timeStamp 03:13 PM 03:28 PM 15 minutes type:support + 2023-01-27 Community: Azure Signin Module authentication_processing_details Issue 02:47 PM 03:13 PM 25 minutes type:support + 2023-01-27 sync 02:31 PM 02:47 PM 16 minutes type:sync + 2023-01-27 🍜 Lunch 01:11 PM 02:31 PM an hour + 2023-01-27 Community: Fix parsing error client port is blank and adjust for timeStamp 12:19 PM 12:29 PM 10 minutes type:support + 2023-01-27 Community: Fix parsing error client port is blank and adjust for timeStamp 11:54 AM 12:06 PM 11 minutes type:support + 2023-01-27 Community: Azure Signin Module authentication_processing_details Issue 09:30 AM 11:54 AM 2 hours type:support + 2023-01-27 sync 08:34 AM 09:30 AM 56 minutes type:sync + 2023-01-27 toggl-track: list time entries 07:04 AM 08:34 AM an hour + For help, run: toggl-track --help diff --git a/setup.py b/setup.py index 48b9548..9ef98a2 100644 --- a/setup.py +++ b/setup.py @@ -31,9 +31,18 @@ def get_long_description(): [console_scripts] tgl=toggl_track.cli:cli """, - install_requires=["click"], + install_requires=[ + "click", + "humanize", + "pydantic", + "requests", + "rich", + ], extras_require={ - "test": ["pytest"] + "test": [ + "pytest", + "pytest-recording", + ] }, python_requires=">=3.7", ) diff --git a/tests/cassettes/test_entries/test_entries.yaml b/tests/cassettes/test_entries/test_entries.yaml new file mode 100644 index 0000000..ffeb11d --- /dev/null +++ b/tests/cassettes/test_entries/test_entries.yaml @@ -0,0 +1,81 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.28.2 + method: GET + uri: https://api.track.toggl.com/api/v9/me/time_entries?start_date=2023-01-26T00%3A00%3A00Z&end_date=2023-01-27T00%3A00%3A00Z + response: + body: + string: !!binary | + H4sIAAAAAAAEA82Z3YrjNhTHX0X4thnQhyXbutvOdGGhpYUpFLosgybRJN44tmvLm86WvkEXel0K + veoL9Mn6CD124iSKUidkbLOQm8gmkc7v/M+X3v7ixTNP0pBEhHIcBRNvnRXLMldT/VA/ISHhmIQT + Ly+y93pqNotB6DMeUFg2qlw2a2mVJBPvMU4S9ZhoTz6ppNQTrzSqMJ70KKbsBpMbKr6nVFIuGf8C + Y4mxV7+T5c4r3JeM/QhPZ1WhTJylsJeAwT/OdDkt4nyz5N1mq1WVxuZZoldJkq1RrooyTucoe0Jv + vvsgkJrNCl2WukRxCp+5Lg3K41wncarh142al55865nnXMuyyvMMtvuuWYdj1Y8IxUGIBYZF2EqW + Js+eNEUFZ1PuwZpd7w+miw+6eJjpRBs9e6jf35ipKmG5MbyghDE28Srr29o2ffOttfmvkz0zHFIq + 6BjMiPS5JGR/tFPMGqw2M+pTv4PZ6/jnHTFdFFmBpkmsU0AEHFBcosdEpUuk0hmAfF8Buyd4x8Qr + fW/UKu8f4JFnjgHQH0V0G4C4C+CWsQ2QjEBvIwqQ4kZym6/nxOZ45BisyHiscHieFbZZQST43+jY + l9JewOrA+QZnhRnh4LrDJzMsWST94CwrHNmsWES7cL36WBUa3cfzFNLWN9msSjRSlVlAbIynTT58 + gIw8hcwGOQ0yjFFxUqI3ZVn1ntUaoeFof8KB4YVhAHntMqHREPskYviqQoSEkkeSH7ilm9S2eG12 + PKoLJVtq//716R90F6epLnZZ6SqtOA7Vo7lbcx3UEGFAfIqhAhlcKiSQhEHpt3ckx9w1kVDSo7pP + 0Kiz7vtspNJuf3/CHtmREwVgKETILywAW/bXSUVIH8B0SaXFa0sFugfuSuWP39D9ojIGivA5WkKd + jR61WWudokW2gkAH5d56oYyG6vlFYmr3NAiQ1qCHYuJCYH5Z6NrxvA4IlxyaqIOo7Ippw4zaQBgR + IHY7dh02UZ+PmLbbH4TdzvgWPD/iPnjr8JGQQ8Ugib8/mgtvy9eGd0JMd0WWo4VWM12gJJujuq+t + W93bJKtmr6FfNQhklJpyJ6R+ml3iOODQsc4XBBPI9GPgwUz6Xc0unL4haOOhgjja+ipRJZRs36ZN + RSfRTxVMIGCcUaKnIluhO53GH9FtVi6rtH9CR142NCEakCAYRUBMYtI9QiIwP4JSStiEOPPdhrZW + CtTYMEXKijoh3aAftF7CnKcZA+3HQyutDTy3x0NRxAQkufPToXZDe9EPjQPmZTwQFwmmzWXX5SIK + KCTr6oGg8INp3zEN4kfRcS6COvpP9HWVThc7819TRsMfAn5+EGJ7tHZrrcPkQWjI61HX8NGJSgyp + sbMU2wAhtu+DOzi+Xz6n052d28RQr50agZILnHz7z+M5OQ64CEbJChBw4HPG7g0a2+6U1FnLrrjm + Clp5GGRmayfqvzDKON7Ro9+frJowjKBHuTcgoGcoOrrGYmTD6Mjx6xH7MQBV17lUwji5uTWonFHy + PFPJSRWwC1Sw3cZoKgiCkNH6lmbw6IOhC/Ql6ew7tpxsFUTYjfW9Rx/HQwZ2/iDAkGMhh11g94hj + Gom6Wb/izgwHcEcn/YN05nQMQAY+5HgkzARUAHbwMdl8ntyYQk2XEvoFuFSpL1QQNAlFrPd9wjVp + t93EIJ7fWvAg7QYwAMER2HRwAE03yrv8HhjBvAEfDa+ICJ0LsCEBbDcxHgAGN8eX5d+2bLpOAaIu + ezrTb51765GuHXgo908UmX//jr4stFo+QXe2K4Cu8njHMXqMOa3FLI8nGBrhETy+vu/t7rHA4A0T + 2+CMRs7Ib0iPdxyjRwC7kPPuP1wjPywqIQAA + headers: + Alt-Svc: + - h3=":443"; ma=2592000,h3-29=":443"; ma=2592000 + Cache-Control: + - no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0 + Content-Encoding: + - gzip + Content-Type: + - application/json; charset=utf-8 + Date: + - Fri, 27 Jan 2023 07:32:41 GMT + Instance: + - time-public-api2 + Referrer-Policy: + - strict-origin-when-cross-origin + Server: + - nginx + Strict-Transport-Security: + - max-age=15552000; includeSubDomains + Transfer-Encoding: + - chunked + Vary: + - Accept-Encoding + Via: + - 1.1 google + X-Content-Type-Options: + - nosniff + X-Request-ID: + - 30ab28ce94254c279dcb9a3437e2470e + X-Service-Level: + - GREEN + X-Toggl-Request-Id: + - 30ab28ce94254c279dcb9a3437e2470e + X-We-are-hiring: + - https://toggl.com/jobs/ + status: + code: 200 + message: OK +version: 1 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3527da0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +from typing import Any + +import pytest +from click.testing import Result + + +@pytest.fixture(scope="module") +def vcr_config(): + return { + "filter_headers": ["authorization"], + } + + +@pytest.fixture() +def save_to_tmp(): + """Saves the result object to the filesystem for inspection.""" + def wrapper(output: Any): + with open("/tmp/output.txt", "w") as f: + f.write(str(output)) + return wrapper diff --git a/tests/test_entries.py b/tests/test_entries.py new file mode 100644 index 0000000..d51dcf2 --- /dev/null +++ b/tests/test_entries.py @@ -0,0 +1,105 @@ +import click.testing +import pytest +from click.testing import CliRunner + +from toggl_track.cli import cli + + +env = { + "TOGGL_API_TOKEN": "1234567890abcdef1234567890abcdef", # fake token for testing +} + + +@pytest.mark.vcr +@pytest.mark.block_network +def test_entries(): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke( + cli, + ["entries", "list", "--start-date", "2023-01-26", "--end-date", "2023-01-27"], + env=env, + ) + assert result.exit_code == 0 + assert ( + result.output + == """ Time Entries + + At Description Start Stop Duration Tags + ────────────────────────────────────────────────────────────────────────────── + 2023-01-26 Community: 10:25 PM 10:54 PM 28 minutes type:support + Allow parsing + of IPv6 + addresses in + ingest + pipeline + 2023-01-26 Community: 09:45 PM 10:25 PM 40 minutes type:support + Fix parsing + error client + port is blank + and adjust + for timeStamp + 2023-01-26 Community: 09:45 PM 09:45 PM a second + Fix parsing + error client + port is blank + and adjust + for timeStamp + 2023-01-26 Community: 09:45 PM 09:45 PM 2 seconds + Fix parsing + error client + port is blank + and adjust + for timeStamp + 2023-01-26 Community: 08:39 PM 09:45 PM an hour type:support + Azure Signin + Module + authenticati… + Issue + 2023-01-26 🍲 Dinner 06:59 PM 08:39 PM an hour + 2023-01-26 Community: 05:13 PM 06:58 PM an hour type:support + Azure Signin + Module + authenticati… + Issue + 2023-01-26 🚌 Shuttling 04:48 PM 05:13 PM 25 minutes + kids between + home and + whatever + 2023-01-26 Community: 03:55 PM 04:48 PM 52 minutes type:support + Azure Signin + Module + authenticati… + Issue + 2023-01-26 Drop header 03:47 PM 03:55 PM 8 minutes type:support + log line in + CloudFront + events + 2023-01-26 ElasticOnAzu… 03:03 PM 03:47 PM 43 minutes type:support + questions + from Deniz + Coskun + 2023-01-26 Cloud 01:01 PM 02:30 PM an hour type:meeting + Monitoring - + Weekly + 2023-01-26 🍜 Lunch 12:35 PM 01:00 PM 24 minutes + 2023-01-26 sync 12:06 PM 12:35 PM 28 minutes type:sync + 2023-01-26 gather town 11:31 AM 12:06 PM 35 minutes type:meeting + 2023-01-26 azure2: 10:55 AM 11:31 AM 35 minutes type:goal + follow up + 2023-01-26 sync 08:24 AM 10:55 AM 2 hours type:sync + 2023-01-26 toggl-track: 07:28 AM 08:08 AM 39 minutes + list time + entries + 2023-01-26 toggl-track: 06:48 AM 07:17 AM 28 minutes + list time + entries + 2023-01-26 🥐 Breakfast 06:06 AM 06:48 AM 42 minutes + 2023-01-26 toggl-track: 05:11 AM 06:06 AM 54 minutes + list time + entries + + +""" + ) + diff --git a/tests/test_result.py b/tests/test_result.py new file mode 100644 index 0000000..33cf556 --- /dev/null +++ b/tests/test_result.py @@ -0,0 +1,37 @@ +from datetime import datetime + +from toggl_track.result import TimeEntriesListResult +from toggl_track.toggl import TimeEntry + + +def test_empty_timeentrieslistresult_as_str(): + entries = [] + result = TimeEntriesListResult(entries) + + assert str(result) == "No time entries found." + + +def test_empty_timeentrieslistresult_as_str(): + entries = [TimeEntry( + id=1, + workspace_id=1, + user_id=1, + project_id=1, + task_id=None, + billable=False, + at=datetime(2021, 1, 25, 0, 0), + description="community:", + start=datetime(2021, 1, 25, 23, 4), + stop=datetime(2021, 1, 25, 23, 27), + duration=1379, + tags=["type:support"] + )] + result = TimeEntriesListResult(entries) + + assert str(result) == """ Time Entries + + At Description Start Stop Duration Tags + ──────────────────────────────────────────────────────────────────────────── + 2021-01-25 community: 11:04 PM 11:27 PM 22 minutes type:support + +""" diff --git a/toggl_track/cli.py b/toggl_track/cli.py index 2605451..61f3f85 100644 --- a/toggl_track/cli.py +++ b/toggl_track/cli.py @@ -1,5 +1,23 @@ +import datetime +from datetime import timedelta + import click +from .toggl import TimeEntries +from .result import TimeEntriesListResult + + +# default reference date for all date options +now = datetime.datetime.now() + +def as_str(reference_date: datetime = now) -> str: + """Formats a `reference_date` into a string. + + Helper function to be used as a default value for click options. + + :param reference_date: a datetime object""" + return reference_date.strftime("%Y-%m-%dT%H:%M:%S") + @click.group() @click.version_option() @@ -7,15 +25,30 @@ def cli(): "CLI tool and Python library to access Toggl Track https://toggl.com/track/" -@cli.command(name="command") -@click.argument( - "example" +@cli.group() +def entries(): + "Time entries commands" + pass + + + +@entries.command(name="list") +@click.option( + "--start-date", + type=click.DateTime(), + default=as_str(now - timedelta(hours=24)), + help="Start date (default: 24 hours ago)" ) @click.option( - "-o", - "--option", - help="An example option", + "--end-date", + type=click.DateTime(), + default=as_str(now) ) -def first_command(example, option): - "Command description goes here" - click.echo("Here is some output") +def list_entries(start_date: datetime, end_date: datetime): + """Returns a list of the latest time entries (default: last 24 hours)""" + + client = TimeEntries.from_environment() + + click.echo(TimeEntriesListResult( + client.list(start_date, end_date)) + ) diff --git a/toggl_track/result.py b/toggl_track/result.py new file mode 100644 index 0000000..df2914e --- /dev/null +++ b/toggl_track/result.py @@ -0,0 +1,54 @@ +import io +from typing import Optional, List + +from humanize import naturaldelta +from rich import box +from rich.console import Console +from rich.table import Table + +from .toggl import TimeEntry + + +class TimeEntriesListResult(object): + """Turns a list of TimeEntry objects into a rich table""" + + def __init__(self, entries: List[TimeEntry]) -> None: + self.entries = entries + + def __str__(self) -> str: + """Returns a rich table as a string.""" + + if not self.entries: + return "No time entries found." + + table = Table(title="Time Entries", box=box.SIMPLE) + table.add_column("At") + table.add_column("Description") + table.add_column("Start") + table.add_column("Stop") + table.add_column("Duration") + table.add_column("Tags") + + for e in self.entries: + table.add_row( + e.at.strftime("%Y-%m-%d"), + e.description, + e.start.strftime("%I:%M %p"), + "" if not e.stop else e.stop.strftime("%I:%M %p"), + "" if e.duration < 0 else naturaldelta(e.duration), + "" if not e.tags else "".join(e.tags), + # "", + # "2023-01-25", + # "community: https://github.com/elastic/beats/issues/34330", + # "11:04 PM", + # "11:27 PM", + # "0:22:59", + # "type:support" + ) + + # turn table into a string using the Console + console = Console(file=io.StringIO()) + console.print(table) + + # click.echo(console.file.getvalue()) + return console.file.getvalue() diff --git a/toggl_track/toggl.py b/toggl_track/toggl.py new file mode 100644 index 0000000..674d043 --- /dev/null +++ b/toggl_track/toggl.py @@ -0,0 +1,66 @@ +import os +import urllib +from datetime import datetime +from typing import Optional, List + +import requests +from pydantic import BaseModel, parse_raw_as +from typing import List + + +class TimeEntry(BaseModel): + id: int + workspace_id: int + user_id: int + project_id: int + task_id: Optional[int] + billable: bool + at: datetime + description: str + start: datetime + stop: Optional[datetime] + duration: int + tags: Optional[List[str]] + + +class TimeEntries(object): + """TimeEntries API client.""" + + def __init__(self, api_token: str) -> None: + self.api_token = api_token + + @classmethod + def from_environment(cls) -> "TimeEntries": + """Creates a new `TimeEntries` instance from the `TOGGL_API_TOKEN` environment variable.""" + if "TOGGL_API_TOKEN" not in os.environ: + raise Exception( + "TOGGL_API_TOKEN environment variable not found. " + "Please set it to your Toggl Track API token." + ) + return cls(api_token=os.environ["TOGGL_API_TOKEN"]) + + def list(self, start_date: datetime, end_date: datetime) -> List[TimeEntry]: + """Fetches the time entries between `start_date` and `end_date` dates. + + Time Entries API v9 + https://developers.track.toggl.com/docs/api/time_entries + """ + params = dict( + start_date=start_date.isoformat() + "Z", + end_date=end_date.isoformat() + "Z", + ) + + # print(params) + + url = ( + "https://api.track.toggl.com/api/v9/me/time_entries?" + + urllib.parse.urlencode(params) + ) + + resp = requests.get(url, auth=(self.api_token, "api_token")) + # print(resp.text) + + if resp.status_code != 200: + raise Exception(resp.text) + + return parse_raw_as(List[TimeEntry], resp.text)