├── .gitignore ├── requirements.txt ├── .travis.yml ├── COPYING ├── setup.py ├── README.rst ├── intheam-cli ├── test.py └── intheam.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | .*.sw[po] 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Recipe from: https://caremad.io/blog/setup-vs-requirement/ 2 | -e . 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.4" 4 | install: 5 | - pip install green coverage coveralls 6 | - pip install -r requirements.txt 7 | script: 8 | - green -r -v -s 8 9 | after_success: coveralls 10 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Adrián Pérez de Castro 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # vim:fenc=utf-8 4 | # 5 | # Copyright © 2015-2016 Adrian Perez 6 | # 7 | # Distributed under terms of the MIT license. 8 | 9 | from setuptools import setup 10 | from codecs import open 11 | from os import path 12 | import sys 13 | 14 | 15 | def file_contents(*relpath): 16 | with open(path.join(path.dirname(__file__), *relpath), "rU", 17 | encoding="utf-8") as f: 18 | return f.read() 19 | 20 | 21 | if __name__ == "__main__": 22 | setup( 23 | name="intheam", 24 | version="0.0.3", 25 | description="Wrapper around the inthe.am API", 26 | long_description=file_contents("README.rst"), 27 | author="Adrián Pérez de Castro", 28 | author_email="adrian@perezdecastro.org", 29 | url="https://github.com/aperezdc/intheam-python", 30 | py_modules=["intheam"], 31 | scripts=["intheam-cli"], 32 | install_requires=[ 33 | "gnarl>=0.1.0a3", 34 | "aiohttp>=0.16.0", 35 | ], 36 | extras_require={ 37 | "cli": ["click>=4.0.0"], 38 | }, 39 | license="MIT", 40 | classifiers=[ 41 | "Development Status :: 3 - Alpha", 42 | "Intended Audience :: Developers", 43 | "Natural Language :: English", 44 | "Programming Language :: Python :: 3.4", 45 | "Programming Language :: Python", 46 | "Operating System :: OS Independent" 47 | ], 48 | test_suite="test", 49 | ) 50 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========================= 2 | InThe.AM API for Python 3 | ========================= 4 | 5 | .. image:: https://img.shields.io/travis/aperezdc/intheam-python.svg?style=flat 6 | :target: https://travis-ci.org/aperezdc/intheam-python 7 | :alt: Build Status 8 | 9 | .. image:: https://img.shields.io/coveralls/aperezdc/intheam-python/master.svg?style=flat 10 | :target: https://coveralls.io/r/aperezdc/intheam-python?branch=master 11 | :alt: Code Coverage 12 | 13 | Python client module for the `Inthe.AM `_ 14 | `REST API `_: 15 | 16 | .. code-block:: python 17 | 18 | import asyncio, intheam, os 19 | 20 | def print_task_list_ids(result): 21 | for task in (yield from result): 22 | print(task.uuid) 23 | 24 | api = intheam.InTheAm(os.getenv("INTHEAM_API_TOKEN")) 25 | asyncio.get_event_loop().run_until_complete( 26 | print_task_list_ids(api.pending())) 27 | 28 | 29 | Installation 30 | ============ 31 | 32 | The stable releases are uploaded to `PyPI `_, so you 33 | can install them and upgrade using ``pip``:: 34 | 35 | pip install intheam 36 | 37 | Alternatively, you can install development versions —at your own risk— 38 | directly from the Git repository:: 39 | 40 | pip install -e git://github.com/aperezdc/intheam-python 41 | 42 | 43 | Documentation 44 | ============= 45 | 46 | There is no documentation for now. In the meanwhile, please read the source 47 | code. 48 | 49 | 50 | Development 51 | =========== 52 | 53 | If you want to contribute, please use the usual GitHub workflow: 54 | 55 | 1. Clone the repository. 56 | 2. Hack on your clone. 57 | 3. Send a pull request for review. 58 | 59 | If you do not have programming skills, you can still contribute by `reporting 60 | issues `_ that you may 61 | encounter. 62 | -------------------------------------------------------------------------------- /intheam-cli: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # vim:fenc=utf-8 4 | # 5 | # Copyright © 2015 Adrian Perez 6 | # 7 | # Distributed under terms of the MIT license. 8 | 9 | import asyncio 10 | import intheam 11 | import click 12 | import os 13 | 14 | 15 | @click.group() 16 | @click.option("--token", envvar="INTHEAM_TOKEN", required=True, 17 | help="inthe.am API token") 18 | @click.pass_context 19 | def cli(ctx, token): 20 | if not token or ":" not in token: 21 | raise SystemExit("Invalid/missing API access token") 22 | ctx.obj = intheam.InTheAm(token) 23 | 24 | 25 | def print_task_list(result): 26 | for task in (yield from result): 27 | click.echo("{0.uuid!s} {0.description!s}".format(task)) 28 | 29 | def pprint_result(result): 30 | from pprint import pformat 31 | text = pformat((yield from result)) 32 | if text.count("\n") > 25: 33 | click.echo_via_pager(text) 34 | else: 35 | click.echo(text) 36 | 37 | 38 | print_fields = ( 39 | "description", 40 | "project", 41 | "id", 42 | "status", 43 | "priority", 44 | "entry", 45 | "due", 46 | "scheduled", 47 | "modified", 48 | "start", 49 | "progress", 50 | ) 51 | def print_task(result, verbose=False): 52 | task = yield from result 53 | fstr = "{!s:<25}: {!s}" if verbose else "{!s:<12}: {!s}" 54 | for field in sorted(task.keys()) if verbose else print_fields: 55 | value = getattr(task, field, None) 56 | if value is None: 57 | continue 58 | click.echo(fstr.format(field, value)) 59 | 60 | 61 | @cli.command("user-status") 62 | @click.pass_obj 63 | def user_status(api): 64 | """ 65 | Status of the user account. 66 | """ 67 | asyncio.get_event_loop().run_until_complete( 68 | pprint_result(api.user_status())) 69 | 70 | 71 | @cli.command() 72 | @click.pass_obj 73 | def pending(api): 74 | """ 75 | Show a list of pending tasks. 76 | """ 77 | asyncio.get_event_loop().run_until_complete( 78 | print_task_list(api.pending())) 79 | 80 | 81 | @cli.command() 82 | @click.pass_obj 83 | def completed(api): 84 | """ 85 | Show a list of completed tasks. 86 | """ 87 | asyncio.get_event_loop().run_until_complete( 88 | print_task_list(api.completed())) 89 | 90 | 91 | @cli.command() 92 | @click.argument("uuid", "Unique identifier of the task", type=click.UUID) 93 | @click.option("--verbose/--quiet", default=False, 94 | help="Show all the fields of the task") 95 | @click.pass_obj 96 | def task(api, uuid, verbose): 97 | """ 98 | Show the details of a task. 99 | """ 100 | asyncio.get_event_loop().run_until_complete( 101 | print_task(api.task(str(uuid)), verbose)) 102 | 103 | 104 | if __name__ == "__main__": cli() 105 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # vim:fenc=utf-8 4 | # 5 | # Copyright © 2015-2016 Adrian Perez 6 | # 7 | # Distributed under terms of the MIT license. 8 | 9 | import gnarl 10 | import intheam 11 | import unittest 12 | 13 | 14 | class TestAnnotation(unittest.TestCase): 15 | def test_create_description_only(self): 16 | ann = intheam.Annotation("Foo bar") 17 | self.assertIsInstance(ann, intheam.Annotation) 18 | self.assertEqual("Foo bar", ann.description) 19 | # A default value for the "entry" date must have been created 20 | self.assertIsInstance(ann.entry, gnarl.Timestamp) 21 | 22 | def test_create_datetime(self): 23 | now = intheam.SchemaDate.now() 24 | ann = intheam.Annotation("Foo bar", now) 25 | self.assertIsInstance(ann, intheam.Annotation) 26 | self.assertEqual("Foo bar", ann.description) 27 | self.assertIsInstance(ann.entry, intheam.SchemaDate) 28 | self.assertEqual(ann.entry, now) 29 | 30 | def test_create_string_date(self): 31 | ann = intheam.Annotation("Foo bar", "Mon, 22 Jun 2015 23:13:31 +0100") 32 | self.assertIsInstance(ann, intheam.Annotation) 33 | # Conversion should have happened automatically 34 | self.assertIsInstance(ann.entry, intheam.SchemaDate) 35 | 36 | def test_create_no_date(self): 37 | ann = intheam.Annotation("Foo bar", None) 38 | self.assertIsInstance(ann, intheam.Annotation) 39 | self.assertIs(ann.entry, None) 40 | 41 | def test_create_from_dict(self): 42 | data = { 43 | "description" : "Foo bar", 44 | "entry" : "Mon, 22 Jun 2015 22:46:00 +0100", 45 | } 46 | ann = intheam.Annotation.validate(data) 47 | self.assertIsInstance(ann, intheam.Annotation) 48 | self.assertIsInstance(ann.entry, gnarl.Timestamp) 49 | from delorean.interface import parse 50 | self.assertEqual(ann.entry, parse(data["entry"])) 51 | 52 | def test_create_from_dict_datetime(self): 53 | data = { 54 | "description" : "Foo bar", 55 | "entry" : intheam.SchemaDate.validate("Mon, 22 Jun 2015 22:46:00 +0100"), 56 | } 57 | ann = intheam.Annotation.validate(data) 58 | self.assertIsInstance(ann, intheam.Annotation) 59 | self.assertIsInstance(ann.entry, gnarl.Timestamp) 60 | 61 | def test_create_from_dict_no_date(self): 62 | ann = intheam.Annotation.validate({ 63 | "description" : "Foo bar", "entry" : None}) 64 | self.assertIsInstance(ann, intheam.Annotation) 65 | self.assertIs(ann.entry, None) 66 | 67 | def test_to_json(self): 68 | ann = intheam.Annotation("Foo bar", "Mon, 22 Jun 2015 00:00:00 +0000") 69 | json = ann.to_json(sort_keys=True) 70 | self.assertEqual(json, 71 | '{"description": "Foo bar", "entry": "Mon, 22 Jun 2015 00:00:00 +0000"}') 72 | 73 | def test_data_keys(self): 74 | ann = intheam.Annotation("Foo bar") 75 | self.assertEqual(list(sorted(ann.keys())), ["description", "entry"]) 76 | 77 | def test_validate_instance(self): 78 | ann = intheam.Annotation("Foo bar") 79 | r = intheam.Annotation.validate(ann) 80 | self.assertIs(r, ann) 81 | 82 | def test_validate_string(self): 83 | ann = intheam.Annotation.validate("Foo bar") 84 | self.assertIsInstance(ann, intheam.Annotation) 85 | self.assertIs(ann.entry, None) 86 | self.assertEqual(ann.description, "Foo bar") 87 | -------------------------------------------------------------------------------- /intheam.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # vim:fenc=utf-8 4 | # 5 | # Copyright © 2015-2016 Adrian Perez 6 | # 7 | # Distributed under terms of the MIT license. 8 | 9 | """ 10 | Python module for accessing the inthe.am API. 11 | 12 | The API consumed by this module is described here: 13 | http://intheam.readthedocs.org/en/latest/api/index.html 14 | """ 15 | 16 | import gnarl 17 | import aiohttp 18 | import uuid 19 | 20 | 21 | BASE_URL = "https://inthe.am/api/v1" 22 | 23 | 24 | class Priority(gnarl.Enum): 25 | HIGH = "H" 26 | MEDIUM = "M" 27 | LOW = "L" 28 | 29 | class Status(gnarl.Enum): 30 | PENDING = "pending" 31 | COMPLETED = "completed" 32 | WAITING = "waiting" 33 | DELETED = "deleted" 34 | 35 | 36 | class SchemaDate(gnarl.Timestamp): 37 | __format__ = gnarl.Timestamp.FORMAT_RFC_2822 38 | 39 | SchemaString = gnarl.And(str, len) 40 | 41 | 42 | class Annotation(gnarl.Schemed): 43 | __schema__ = { 44 | "description" : SchemaString, 45 | "entry" : gnarl.Or(None, SchemaDate), 46 | } 47 | __NOW = object() 48 | 49 | def __init__(self, description, entry=__NOW): 50 | if entry is self.__NOW: 51 | entry = SchemaDate.now() 52 | super(Annotation, self).__init__( 53 | description=description, 54 | entry=entry) 55 | 56 | @classmethod 57 | def validate(cls, data): 58 | if isinstance(data, str): 59 | return cls(data, None) 60 | else: 61 | return super(Annotation, cls).validate(data) 62 | 63 | 64 | class Task(gnarl.Schemed): 65 | __schema__ = { 66 | "description" : SchemaString, 67 | "status" : gnarl.Or(None, Status), 68 | "priority" : gnarl.Or(None, Priority), 69 | "id" : gnarl.UUID, 70 | "annotations" : [Annotation], 71 | "blocks" : [gnarl.UUID], 72 | "depends" : [gnarl.UUID], 73 | "due" : gnarl.Or(None, SchemaDate), 74 | "entry" : SchemaDate, 75 | "modified" : SchemaDate, 76 | "progress" : gnarl.Or(None, float), 77 | "project" : gnarl.Or(None, SchemaString), 78 | "scheduled" : gnarl.Or(None, SchemaDate), 79 | "start" : gnarl.Or(None, SchemaDate), 80 | "short_id" : int, 81 | "urgency" : float, 82 | "tags" : [SchemaString], 83 | 84 | # Optional values added by the inthe.am API, 85 | # which locally created tasks do not have 86 | "resource_uri" : gnarl.Optional(gnarl.Or(None, SchemaString)), 87 | "url" : gnarl.Optional(gnarl.Or(None, SchemaString)), 88 | "uuid" : gnarl.Optional(gnarl.UUID), 89 | 90 | # Validated but ignored. 91 | "imask" : gnarl.Or(None, str), 92 | "wait" : gnarl.Or(None, gnarl.UUID), 93 | 94 | # inthe.am specific values; also validated but not used. 95 | # TODO: Check whether those are completely correct 96 | "intheamattachments" : gnarl.Optional(gnarl.Or(None, [SchemaString])), 97 | "intheamkanbanassignee" : gnarl.Optional(gnarl.Or(None, str)), 98 | "intheamkanbanboarduuid" : gnarl.Optional(gnarl.Or(None, gnarl.UUID)), 99 | "intheamkanbancolor" : gnarl.Optional(gnarl.Or(None, str)), 100 | "intheamkanbancolumn" : gnarl.Optional(gnarl.Or(None, str)), 101 | "intheamkanbansortorder" : gnarl.Optional(gnarl.Or(None, str)), 102 | "intheamkanbantaskuuid" : gnarl.Optional(gnarl.Or(None, gnarl.UUID)), 103 | "intheamoriginalemailid" : gnarl.Optional(gnarl.Or(None, str)), 104 | "intheamoriginalemailsubject" : gnarl.Optional(gnarl.Or(None, str)), 105 | "intheamtrelloid" : gnarl.Optional(gnarl.Or(None, str)), 106 | "intheamtrelloboardid" : gnarl.Optional(gnarl.Or(None, str)), 107 | } 108 | 109 | def __init__(self, api=None, **kw): 110 | super(Task, self).__init__(**kw) 111 | self.__api = api 112 | 113 | def refresh_data(self): 114 | return self.__api.refresh_task(self) 115 | 116 | def mark_started(self): 117 | return self.__api.start_task(self) 118 | 119 | def mark_stopped(self): 120 | return self.__api.stop_task(self) 121 | 122 | def delete(self): 123 | return self.__api.delete_task(self) 124 | 125 | 126 | class InTheAmError(Exception): 127 | """Exception class uses for inthe.am API errors.""" 128 | def __init__(self, response): 129 | error_line = self.ERROR_MAP.get(response.status, 130 | "Unspecified/unknown error") 131 | super(InTheAmError, self).__init__( 132 | "{!s}: {}\n{!s}".format( 133 | response.status, 134 | error_line, 135 | response.text())) 136 | 137 | class NotFound(InTheAmError): 138 | """Exception raised when a resource is not found.""" 139 | 140 | class NotAuthenticated(InTheAmError): 141 | """Exception raised on unauthorized/unauthenticated access a resource.""" 142 | 143 | 144 | class InTheAm(object): 145 | def __init__(self, auth_token, base_url=BASE_URL): 146 | self.auth_token = str(auth_token) 147 | self.base_url = str(base_url) 148 | self._session = aiohttp.ClientSession(headers={ 149 | "Authorization": "ApiKey " + self.auth_token, 150 | }) 151 | 152 | def __del__(self): 153 | self._session.close() 154 | 155 | def pending(self): 156 | response = yield from self._session.get(self.base_url + "/task/") 157 | body = yield from response.json() 158 | return (Task(api=self, **item) for item in body.get("objects", ())) 159 | 160 | def completed(self): 161 | response = yield from self._session.get( 162 | self.base_url + "/completedtask/") 163 | body = yield from response.json() 164 | return (Task(api=self, **item) for item in body.get("objects", ())) 165 | 166 | def user_status(self): 167 | response = yield from self._session.get( 168 | self.base_url + "/user/status/") 169 | return (yield from response.json()) 170 | 171 | def __get_task_dict(self, task_uuid): 172 | response = yield from self._session.get( 173 | "{.base_url}/task/{!s}/".format(self, task_uuid)) 174 | return (yield from response.json()) 175 | 176 | def __check_response(self, response, json=False): 177 | if response.status in (200, 201): 178 | if json: 179 | return (yield from response.json()) 180 | else: 181 | return (yield from response.text()) 182 | elif response.status in (401, 403): 183 | raise NotAuthenticated(response) 184 | elif response.status == 404: 185 | raise NotFound(response) 186 | else: 187 | raise InTheAmError(response) 188 | 189 | def task(self, task_uuid): 190 | return Task(api=self, **(yield from self.__get_task_dict(task_uuid))) 191 | 192 | def refresh_task(self, task): 193 | return task.update(self.__get_task_dict(task.uuid)) 194 | 195 | def save_task(self, task): 196 | response = yield from self._session.put( 197 | "{.base_url}/task/{.uuid!s}/".format(self, task), 198 | data=task.to_json()) 199 | _ = yield from self.__check_response(response) 200 | return self.refresh_task(task) 201 | 202 | def start_task(self, task): 203 | response = yield from self._session.post( 204 | "{.base_url}/task/{.uuid!s}/".format(self, task)) 205 | _ = yield from self.__check_response(response) 206 | return self.refresh_task(task) 207 | 208 | def stop_task(self, task): 209 | response = yield from self._session.post( 210 | "{.base_url}/task/{.uuid!s}/".format(self, task)) 211 | _ = yield from self.__check_response(response) 212 | return self.refresh_task(task) 213 | 214 | def delete_task(self, task): 215 | response = yield from self._session.post( 216 | "{.base_url}/task/{.uuid!s}/delete/".format(self, task)) 217 | _ = yield from self.__check_response(response) 218 | return self.refresh_task(task) 219 | 220 | def complete_task(self, task): 221 | response = yield from self._session.delete( 222 | "{.base_url}/task/{.uuid!s}/".format(self, task)) 223 | _ = yield from self.__check_response(response) 224 | return self.refresh_task(task) 225 | --------------------------------------------------------------------------------