├── .gitignore
├── LICENSE
├── README.md
├── setup.py
├── tasks.py
└── tests
├── test_list.py
├── test_move.py
└── tools.py
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependency directory
2 | Tasker.egg-info/
3 |
4 | # other
5 | .tmp
6 | *.ds
7 | *.pyc
8 | .venv
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "{}"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright {yyyy} {name of copyright owner}
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Tasker
2 | Manage your daily tasks via CLI.
3 |
4 |
5 |
6 | *_Strikethrough support is new and not shown in below examples_
7 |
8 | **Commands:**
9 |
10 | _task_
11 | - _add_
12 | - Add a new task to todays list or a specific day (-d YYYY-MM-DD).
13 | - _browse_
14 | - Open browser for a task with a link.
15 | - _done_
16 | - Toggles complete status for a passed index.
17 | - _ls_
18 | - List todays tasks. day (-d YYYY-MM-DD) weekly (-w) monthly (-m)
19 | - mv
20 | - Move a task up/down. Can also reorganize with task values in the order you want them. (See example below)
21 | - Move a task from a date (-d YYYY-MM-DD) to today or another date (-m YYYY-MM-DD).
22 | - _rm_
23 | - Remove a task for the passed index.
24 |
25 | All commands have a `--help` command with more information.
26 |
27 | ## Install
28 | Clone and run `pip install .`
29 |
30 | ## Examples:
31 | **Adding and removing tasks.**
32 |
33 |
34 |
35 | **Adding task with link and browsing.**
36 |
37 |
38 |
39 | **Moving tasks and reorganizing.**
40 |
41 |
42 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup
2 |
3 |
4 | setup(
5 | name='Tasker',
6 | version='1.4',
7 | py_modules=['tasks'],
8 | install_requires=[
9 | 'Click',
10 | 'pyyaml',
11 | 'python-dateutil',
12 | ],
13 | entry_points='''
14 | [console_scripts]
15 | task=tasks:cli
16 | '''
17 | )
18 |
--------------------------------------------------------------------------------
/tasks.py:
--------------------------------------------------------------------------------
1 | """
2 | A python app to manage daily tasks and track them over time.
3 |
4 | Author: Thomas Johns @t0mj
5 | """
6 | import click
7 | import datetime
8 | import os
9 | import yaml
10 |
11 | from collections import OrderedDict
12 | from dateutil.parser import parse
13 | from dateutil.relativedelta import relativedelta
14 | from subprocess import call
15 |
16 |
17 | # Globals
18 | DIR = os.path.expanduser('~/.tasker')
19 | DATA_FILE = os.path.join(DIR, 'task_data.yml')
20 | TODAY = datetime.date.today()
21 | # Output colors
22 | BOX = lambda checked: u"\u2705" if checked else u"\u25FB\uFE0F"
23 | CYAN = lambda txt: click.secho(txt, fg='cyan')
24 | GREEN = lambda txt: click.secho(txt, fg='green', bold=True)
25 | RED = lambda txt: click.secho(txt, fg='red', bold=True)
26 | STRIKE = lambda txt, checked: ''.join([u'{}\u0336'.format(c).encode('utf-8')
27 | for c in txt]) if checked else txt
28 | YELLOW = lambda txt: click.secho(txt, fg='yellow', bold=True)
29 |
30 |
31 | class Tasker(object):
32 | def __init__(self):
33 | self.task_data = {}
34 | if not os.path.exists(DIR):
35 | os.makedirs(DIR)
36 | if not os.path.exists(DATA_FILE):
37 | self.write_data()
38 |
39 | def load_data(self):
40 | stream = file(DATA_FILE, 'r')
41 | self.task_data = self.sort_dict(yaml.load(stream))
42 |
43 | def daily_tasks(self, task_date=TODAY):
44 | # Returns a list with the task for a given day
45 | if type(task_date) != datetime.date:
46 | task_date = parse(task_date).date()
47 | one_days_data = self.task_data.get(task_date, [])
48 | if not one_days_data:
49 | RED('No tasks found on {}'.format(task_date))
50 | return one_days_data
51 |
52 | def query_data(self, query_type):
53 | start_date = TODAY - relativedelta(days=7)
54 | query_data = {}
55 | for task_date, task_list in self.task_data.iteritems():
56 | if query_type == 'weekly':
57 | if task_date <= TODAY and task_date >= start_date:
58 | query_data[task_date] = task_list
59 | elif query_type == 'monthly':
60 | if task_date.month == TODAY.month:
61 | query_data[task_date] = task_list
62 | return self.sort_dict(query_data)
63 |
64 | def sort_dict(self, adict):
65 | return OrderedDict(sorted(adict.iteritems()))
66 |
67 | def write_data(self):
68 | with open(DATA_FILE, 'w') as tdf:
69 | yaml.dump(self.task_data, tdf)
70 |
71 |
72 | pass_tasker = click.make_pass_decorator(Tasker)
73 |
74 |
75 | @click.group()
76 | @click.version_option('1.4')
77 | @click.pass_context
78 | def cli(ctx):
79 | """
80 | Tasker is a CLI to track and manage your daily tasks.
81 | """
82 | ctx.obj = Tasker()
83 | ctx.obj.load_data()
84 |
85 |
86 | @cli.command()
87 | @click.argument('task_title', nargs=-1, required=True)
88 | @click.option('--link', '-l', default=None,
89 | help='Attach a link to your task. Open with: task browse')
90 | @click.option('--task_date', '-d', default=TODAY,
91 | help='Pass a task date, defaults to today.')
92 | @pass_tasker
93 | def add(tasker, task_title, link, task_date):
94 | """
95 | Add a new task to todays list.\n
96 | ex: task add Respond to meanface Sue's email
97 | """
98 | if type(task_date) != datetime.date:
99 | task_date = parse(task_date).date()
100 | tasker.task_data.setdefault(task_date, [])
101 | task_dict = {'title': ' '.join(task_title), 'complete': False}
102 | if link:
103 | task_dict['link'] = link
104 | tasker.task_data[task_date].append(task_dict)
105 | tasker.sort_dict(tasker.task_data)
106 | tasker.write_data()
107 | GREEN('Task added.')
108 |
109 |
110 | @cli.command()
111 | @click.argument('task_idx', type=int, required=True)
112 | @click.option('--task_date', '-d', default=TODAY,
113 | help='Pass a task date, defaults to today.')
114 | @pass_tasker
115 | def browse(tasker, task_idx, task_date):
116 | """
117 | Open browser for a task with a link. (cyan)\n
118 | Examples:\n
119 | task browse 0 -d 2016-01-28\n
120 | task browse 3
121 | """
122 | daily_tasks = tasker.daily_tasks(task_date)
123 | if daily_tasks:
124 | if task_idx >= len(daily_tasks):
125 | RED('No task {} found on {}'.format(task_idx, task_date))
126 | return
127 | task = daily_tasks[task_idx]
128 | if task.get('link'):
129 | call(["open", task['link']])
130 |
131 |
132 | @cli.command()
133 | @click.argument('task_idx', type=int, required=True)
134 | @click.option('--task_date', '-d', default=TODAY,
135 | help='Pass a task date, defaults to today.')
136 | @pass_tasker
137 | def done(tasker, task_idx, task_date):
138 | """
139 | Toggles complete status for a passed index.\n
140 | Examples:\n
141 | task done 2\n
142 | task done 0 -d 2016-01-30
143 | """
144 | daily_tasks = tasker.daily_tasks(task_date)
145 | if daily_tasks:
146 | if task_idx >= len(daily_tasks):
147 | RED('No task {} found on {}'.format(task_idx, task_date))
148 | return
149 | curr_status = daily_tasks[task_idx]['complete']
150 | daily_tasks[task_idx]['complete'] = not curr_status
151 | tasker.write_data()
152 | GREEN('Task marked complete.')
153 |
154 |
155 | @cli.command()
156 | @click.option('--all_tasks', '-a', is_flag=True,
157 | help='List all tasks by date.')
158 | @click.option('--task_date', '-d', default=TODAY,
159 | help='Pass a task date, defaults to today.')
160 | @click.option('--week', '-w', is_flag=True,
161 | help='List all tasks for the last week.')
162 | @click.option('--month', '-m', is_flag=True,
163 | help='List all tasks for the current month.')
164 | @pass_tasker
165 | def ls(tasker, all_tasks, task_date, week, month):
166 | """
167 | List todays tasks.
168 | CYAN tasks have links and can be opened from cli.
169 | Ex: task open 3
170 | """
171 | if all_tasks:
172 | task_dict = tasker.task_data
173 | elif week:
174 | task_dict = tasker.query_data('weekly')
175 | elif month:
176 | task_dict = tasker.query_data('monthly')
177 | else:
178 | task_dict = {task_date: tasker.daily_tasks(task_date)}
179 | for task_date, task_list in task_dict.iteritems():
180 | if not task_list:
181 | continue
182 | click.echo()
183 | YELLOW('Tasks for {}:'.format(task_date))
184 | for idx, task in enumerate(task_list):
185 | complete = task['complete']
186 | extra_space = ' ' if len(task_list) > 10 and idx < 10 else ''
187 | output_str = ('{} {}. {}{}'.format(BOX(complete).encode('utf-8'),
188 | idx,
189 | extra_space,
190 | STRIKE(task['title'],
191 | complete)))
192 | if task.get('link'):
193 | CYAN(output_str)
194 | else:
195 | click.echo(output_str)
196 |
197 |
198 | @cli.command()
199 | @click.argument('task_args', nargs=-1, required=True)
200 | @click.option('--task_date', '-d', default=TODAY,
201 | help='Pass a task date, defaults to today.')
202 | @click.option('--move_date', '-m', default=None,
203 | help='The date to move your task to, defaults to today.')
204 | @pass_tasker
205 | def mv(tasker, task_args, task_date, move_date):
206 | """
207 | Move a task for the passed index.\n
208 | Pass a list in the order you want tasks to completely reorganize.\n
209 | Examples:\n
210 | task mv 1 up\n
211 | task mv 3 down\n
212 | task mv 2, 0, 3, 4, 1\n\n
213 | Move a task from one date to another:\n
214 | task mv 7 -d 2016-01-30 -m 2016-02-02\n
215 | Move a task from a date to today\n
216 | task mv 4 -d 2016-01-30
217 | """
218 | directions = {'up': -1, 'down': 1}
219 | if len(task_args) == 1 and not move_date:
220 | if task_date != TODAY:
221 | # We're moving a task from a date to today
222 | move_date = TODAY
223 | else:
224 | RED('You must give a direction or an order of your tasks.')
225 | return
226 | daily_tasks = tasker.daily_tasks(task_date)
227 | if daily_tasks:
228 | dt_copy = list(daily_tasks)
229 | task_idx = int(task_args[0])
230 | if task_idx >= len(daily_tasks) or task_idx < 0:
231 | RED('Task index out of range.')
232 | return
233 | if move_date:
234 | if type(move_date) != datetime.date:
235 | move_date = parse(move_date).date()
236 | # Get new dates tasks, append, and remove old dates task.
237 | move_date_tasks = tasker.daily_tasks(move_date)
238 | move_date_tasks.append(daily_tasks[task_idx])
239 | daily_tasks.pop(task_idx)
240 | GREEN('{} task {} has been moved to {}.'.format(task_date,
241 | task_idx,
242 | move_date))
243 | elif task_args[1] in directions.keys():
244 | # Find index of where we're moving and overwrite with copied values
245 | new_loc_idx = task_idx + directions[task_args[1]]
246 | if (new_loc_idx >= len(daily_tasks) or new_loc_idx < 0):
247 | RED('Invalid move operation.')
248 | return
249 | daily_tasks[task_idx] = dt_copy[new_loc_idx]
250 | daily_tasks[new_loc_idx] = dt_copy[task_idx]
251 | GREEN('Task {} has been moved to task {}'.format(task_idx,
252 | new_loc_idx))
253 | else:
254 | # We're reordering and need to make sure they gave us all values
255 | total_idx_args = 0
256 | for n in task_args:
257 | total_idx_args += int(n)
258 | total_idx_dt = 0
259 | for task in daily_tasks:
260 | total_idx_dt += daily_tasks.index(task)
261 | if (total_idx_dt == total_idx_args
262 | and len(daily_tasks) == len(task_args)):
263 | # Comparing the total we know each index is represented
264 | # Comparing length we know that all positions are there since
265 | # index 0 doesn't help in the index totals
266 | for idx, val in enumerate(task_args):
267 | daily_tasks[idx] = dt_copy[int(val)]
268 | GREEN('Tasks have been reordered.')
269 | else:
270 | RED('Invalid order or missing task number.')
271 | tasker.sort_dict(tasker.task_data)
272 | tasker.write_data()
273 |
274 |
275 | @cli.command()
276 | @click.argument('task_idx', type=int, required=True)
277 | @click.option('--task_date', '-d', default=TODAY,
278 | help='Pass a task date, defaults to today.')
279 | @pass_tasker
280 | def rm(tasker, task_idx, task_date):
281 | """
282 | Remove a task for the passed index.\n
283 | Examples:\n
284 | task rm 0 -d 2016-01-28\n
285 | task rm 3
286 | """
287 | daily_tasks = tasker.daily_tasks(task_date)
288 | if daily_tasks:
289 | if task_idx >= len(daily_tasks):
290 | RED('No task {} found on {}'.format(task_idx, task_date))
291 | return
292 | daily_tasks.pop(task_idx)
293 | tasker.sort_dict(tasker.task_data)
294 | tasker.write_data()
295 | GREEN('Task removed.')
296 |
--------------------------------------------------------------------------------
/tests/test_list.py:
--------------------------------------------------------------------------------
1 | """
2 | Tests for the list command: task ls
3 |
4 | Author: Thomas Johns @t0mj
5 | """
6 | import unittest
7 | from tools import Commander
8 |
9 |
10 | class TestList(unittest.TestCase):
11 | def setUp(self):
12 | self.commander = Commander()
13 |
14 | def tearDown(self):
15 | self.commander.teardown()
16 | self.commander = None
17 |
18 | def test_ls(self):
19 | task_list = self.commander.command('ls')
20 | assert len(task_list) == 3
21 |
--------------------------------------------------------------------------------
/tests/test_move.py:
--------------------------------------------------------------------------------
1 | """
2 | Tests for the move command: task mv
3 |
4 | Author: Thomas Johns @t0mj
5 | """
6 | import unittest
7 | from tools import Commander
8 |
9 |
10 | class TestMove(unittest.TestCase):
11 | def setUp(self):
12 | self.commander = Commander()
13 |
14 | def tearDown(self):
15 | self.commander.teardown()
16 | self.commander = None
17 |
18 | def test_mv_down(self):
19 | cmd = self.commander.command
20 | original_list = cmd('ls')
21 | message = cmd('mv 0 down')[0]
22 | assert message == 'Task 0 has been moved to task 1'
23 | new_list = cmd('ls')
24 | assert original_list != new_list
25 | assert original_list[1].replace('1', '0') == new_list[0]
26 |
27 | def test_mv_up(self):
28 | cmd = self.commander.command
29 | original_list = cmd('ls')
30 | message = cmd('mv 1 up')[0]
31 | assert message == 'Task 1 has been moved to task 0'
32 | new_list = cmd('ls')
33 | assert original_list != new_list
34 | assert original_list[0].replace('0', '1') == new_list[1]
35 |
36 | def test_mv_errors(self):
37 | cmd = self.commander.command
38 | message = cmd('mv 0 up')[0]
39 | assert message == 'Invalid move operation.'
40 |
41 | message = cmd('mv 2 down')[0]
42 | assert message == 'Invalid move operation.'
43 |
44 | message = cmd('mv -1')[0]
45 | assert message == 'Error: no such option: -1'
46 |
47 | message = cmd('mv 4')[0]
48 | assert message == ('You must give a direction or an order of your '
49 | 'tasks.')
50 |
51 | message = cmd('mv 4 up')[0]
52 | assert message == 'Task index out of range.'
53 |
54 | message = cmd('mv 4 down')[0]
55 | assert message == 'Task index out of range.'
56 |
--------------------------------------------------------------------------------
/tests/tools.py:
--------------------------------------------------------------------------------
1 | """
2 | Tests tools to simulate task data. Commander creates a new class that allows
3 | commands to be run as if they were on the command line.
4 |
5 | Author: Thomas Johns @t0mj
6 | """
7 | import datetime
8 | import mock
9 | import re
10 |
11 | from click.testing import CliRunner
12 | from mock import MagicMock
13 | from tasks import cli, Tasker
14 |
15 |
16 | TEST_DATA = {datetime.date.today(): [
17 | {"complete": False, "title": "Kiss my task"},
18 | {"complete": True, "title": "Foo task"},
19 | {"complete": True, "title": "Tests suck :("}
20 | ]}
21 |
22 |
23 | def load_data(self):
24 | self.task_data = TEST_DATA
25 |
26 |
27 | class Commander(object):
28 | def __init__(self):
29 | self.runner = CliRunner()
30 |
31 | def clean_output(self, output):
32 | output = re.sub(r'[^\x00-\x7F]+', '', output)
33 | output = output.split('\n')
34 | clean_output = []
35 | for str_ in output:
36 | if str_ and 'Tasks for' not in str_:
37 | clean_output.append(str_)
38 | return clean_output
39 |
40 | @mock.patch.object(Tasker, 'load_data', load_data)
41 | @mock.patch.object(Tasker, 'write_data', MagicMock())
42 | def command(self, command):
43 | if not isinstance(command, list):
44 | command = command.split(' ')
45 | result = self.runner.invoke(cli, command)
46 | self.generic_asserts(result)
47 | return self.clean_output(result.output)
48 |
49 | def generic_asserts(self, result):
50 | try:
51 | assert not result.exception
52 | assert result.exit_code == 0
53 | except AssertionError:
54 | # Check if its a click help text
55 | assert result.exit_code == 2
56 | assert 'Error: no such option:' in result.output
57 |
58 | def teardown(self):
59 | self.runner = None
60 |
--------------------------------------------------------------------------------