/
snipper.py
executable file
·507 lines (384 loc) · 17 KB
/
snipper.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
import os
import getpass
import sys
import configparser
import re
import glob
import shutil
import click
import pyperclip
import webbrowser
from prompt_toolkit import prompt
from .api import SnippetApi
from .snippet import Snippet
from .completers import SnippetFileCompleter, SnippetDirCompleter, ValidateSnippetDir, ValidateSnippetFile
from .repo import Repo
from . import utils
DEFAULT_SNIPPET_DIR = os.path.expanduser('~/.snippets')
DEFAULT_SNIPPER_CONFIG = os.path.expanduser('~/.snipperrc')
@click.group(context_settings={'help_option_names': ['-h', '--help']})
@click.option(
'--config', '-C', 'config_file',
default=DEFAULT_SNIPPER_CONFIG,
type=click.Path(),
help='Config file path: Default: {}'.format(DEFAULT_SNIPPER_CONFIG)
)
@click.option(
'--no-color',
default=False,
is_flag=True,
help='Don\'t colorize output',
)
@click.pass_context
def cli(ctx, config_file, no_color, **kwargs):
if not os.path.exists(config_file):
print('Configuration file not found. Plase give me your settings.')
_init_snipper(config_file, not no_color)
# Create config with default values
config = configparser.ConfigParser({
'snippet_dir': DEFAULT_SNIPPET_DIR,
'auto_push': 'yes',
'default_filename': 'file.txt',
'colorize': 'no' if no_color else 'yes',
})
# Overwrite config with user config.
config.read(config_file)
# https protocol not supported because
# user must give password every clone/pull/push.
config.set('snipper', 'protocol', 'ssh')
# Read useraname/password from env vars not exist in config file
if not config.has_option('snipper', 'username') and os.environ.get('SNIPPER_USERNAME'):
config.set('snipper', 'username', os.environ['SNIPPER_USERNAME'])
if not config.has_option('snipper', 'password') and os.environ.get('SNIPPER_PASSWORD'):
config.set('snipper', 'password', os.environ['SNIPPER_PASSWORD'])
ctx.obj = config
def _init_snipper(config_file, colorize):
if os.path.exists(config_file) and not click.confirm('Config file already exist. Overwrite it'):
return
snippet_dir = click.prompt('Where to keep snippets', default=DEFAULT_SNIPPET_DIR)
username = click.prompt('Bitbucket username')
password_help_text = '\n'.join([
"You should give me a password for authenticating to Bitbucket API for accessing your snippets.",
"You can create an app password that only permitted",
"to snippets at settings page on bitbucket.org",
])
utils.secho(colorize, password_help_text, fg='blue')
password = getpass.getpass('App password:')
# Create snippet home dir
if not os.path.exists(snippet_dir):
os.makedirs(snippet_dir)
config = configparser.ConfigParser()
config.read(config_file)
config.add_section('snipper')
config.set('snipper', 'snippet_dir', snippet_dir)
config.set('snipper', 'username', username)
config.set('snipper', 'password', password)
config.set('snipper', 'colorize', 'yes' if colorize else 'no')
config.write(open(config_file, 'w'))
@cli.command(name='ls')
@click.option('-v', 'verbose', default=True, flag_value='short', help='Provides short listing')
@click.option(
'-vv',
'verbose',
flag_value='detailed',
help='Provides the most detailed listing'
)
@click.pass_context
def list_snippets(ctx, verbose):
"""List local snippets"""
config = ctx.obj
colorize = config.getboolean('snipper', 'colorize')
data = utils.read_metadata(config)
long_file_list = []
for item in data['values']:
snippet = Snippet(config, item)
if not snippet.is_exists():
msg = '[{}] Snippet does not exist in snippet directory. Please `pull` or `sync`'.format(item['id'])
utils.secho(colorize, msg, fg='blue')
continue
if verbose == 'short':
utils.secho(colorize, '[{}] {}'.format(item['id'], item['title']), fg='blue')
elif verbose == 'detailed':
# Show files in snippet
onlyfiles = snippet.get_files()
for file_name in onlyfiles:
snippet_path = snippet.get_path()
long_file_list.append(os.path.join(snippet_path, file_name))
if long_file_list:
click.echo_via_pager('\n'.join(long_file_list))
@cli.command(name='pull')
@click.pass_context
def pull_local_snippets(ctx):
"""Update local snippets from Bitbucket.
Pull changes of existing snippets and clone new snippets.
"""
config = ctx.obj
colorize = config.getboolean('snipper', 'colorize')
api = SnippetApi(config)
res = api.get_all()
utils.update_metadata(config, res)
for item in res['values']:
snippet = Snippet(config, item)
if snippet.is_exists():
utils.secho(colorize, '[{}] Pulling ...'.format(snippet.snippet_id), fg='blue')
snippet.pull()
snippet.update_dir_name()
else:
utils.secho(colorize, '[{}] Cloning ...'.format(snippet.snippet_id), fg='blue')
snippet.clone()
utils.secho(colorize, 'Local snippets updated and new snippets downloaded from Bitbucket', fg='blue')
def _edit_snippet_file(ctx, param, relative_path):
"""Open snippet file with default editor"""
config = ctx.obj
colorize = config.getboolean('snipper', 'colorize')
if not relative_path or ctx.resilient_parsing:
return
file_path = os.path.join(config.get('snipper', 'snippet_dir'), relative_path)
if os.path.exists(file_path):
click.edit(filename=file_path)
else:
utils.secho(colorize, 'File not exist. Exiting ...', fg='red')
ctx.exit()
@cli.command(name='edit', help='Edit snippet file')
@click.option('--fuzzy', is_flag=True, default=True, help='Open fuzzy file finder')
@click.argument(
'FILE_PATH',
type=click.Path(),
required=False, is_eager=True, expose_value=False,
callback=_edit_snippet_file
)
@click.pass_context
def edit_snippet_file(ctx, fuzzy, file_path=None):
config = ctx.obj
colorize = config.getboolean('snipper', 'colorize')
utils.secho(colorize, 'Select a file for edit with fuzzy search.', fg="yellow")
utils.secho(colorize, 'Let\'s write some text. Press Ctrl+c for quit', fg="yellow")
selected_file = prompt(
u'> ',
completer=SnippetFileCompleter(config),
validator=ValidateSnippetFile(config)
)
file_path = os.path.join(config.get('snipper', 'snippet_dir'), selected_file)
click.edit(filename=file_path)
snippet_dir_name, _ = os.path.split(selected_file)
repo_dir = os.path.join(config.get('snipper', 'snippet_dir'), snippet_dir_name)
commit_message = u"{} updated".format(selected_file)
Repo.commit(repo_dir, commit_message)
if config.getboolean('snipper', 'auto_push'):
utils.secho(colorize, 'Pushing changes to Bitbucket', fg='blue')
Repo.push(repo_dir)
@cli.command(name='new', help='Create new snippet from file[s]/STDIN')
@click.option('--title', '-t', help='Snippet title', default='')
@click.option('--public', '-p', help='Make snippet public. Private by default', is_flag=True)
@click.option('--hg', '-hg', is_flag=True, help='Use mercurial. Git by default')
@click.option('--copy-url', '-c', help='Copy resulting URL to clipboard', is_flag=True)
@click.option('--open', '-o', help='Open snippet URL on browser after create', is_flag=True)
@click.option('--paste', '-P', help='Create snippet from clipboard', is_flag=True)
@click.option('--filename', '-f', help='Used when content read from STDIN or clipboard')
@click.argument('files', nargs=-1, type=click.Path(exists=True))
@click.pass_context
def new_snippet(ctx, files, **kwargs):
config = ctx.obj
colorize = config.getboolean('snipper', 'colorize')
content_list = []
if files:
# Read files given as positional parameter
content_list.extend(utils.open_files(files))
# if filename is not specified, it is exist everytime as None
# so kwargs.get('filename', default_val) not working
filename = kwargs.get('filename')
if not filename:
filename = config.get('snipper', 'default_filename')
title = kwargs.get('title', None)
# Read from STDIN of clipboard but not both
if not sys.stdin.isatty():
# Read from stdin if stdin has some data
streamed_data = sys.stdin.read()
content_list.append(('file', (filename, streamed_data,),))
utils.secho(colorize, 'New file created from STDIN', fg='blue')
elif kwargs.get('paste'):
# Read from clipboard
clipboard_text = pyperclip.paste()
if clipboard_text:
if not title:
# if title not specified, make title first 50 charecters of first line
title = clipboard_text.split('\n')[0][:50]
content_list.append(('file', (filename, clipboard_text)))
utils.secho(colorize, 'New file created from clipboard content', fg='blue')
else:
utils.secho(colorize, 'Clipboard is empty, ignoring', fg='yellow')
if not content_list:
# click.edit() return None if user closes to editor without saving.
content = click.edit()
if content is None:
utils.secho(colorize, 'Empty content. Exiting', fg='red', err=True)
sys.exit(1)
if content == '':
confirm = click.confirm('Content is empty. Create anyway?')
if not confirm:
sys.exit(1)
if not title:
# if title not specified, pick first 50 charecters of file as title.
title = content.split('\n')[0][:50]
content_list.append(('file', (filename, content)))
scm = 'hg' if kwargs.get('hg') else 'git'
utils.secho(colorize, 'Snippet creating...', fg='blue')
api = SnippetApi(config)
response = api.create_snippet(
content_list,
not kwargs.get('public', False),
title,
scm,
)
if response.ok:
# Update metadata file
metadata = utils.read_metadata(config)
metadata['values'].append(response.json())
utils.update_metadata(config, metadata)
snippet = Snippet(config, response.json())
snippet.clone()
utils.secho(colorize, 'Created snippet cloned from Bitbucket', fg='green')
snipper_url = snippet.data['links']['html']['href']
if kwargs.get('copy_url'):
# Copy snippet url to clipboard
pyperclip.copy(snipper_url)
utils.secho(colorize, 'URL copied to clipboard', fg='green')
if kwargs.get('open'):
webbrowser.open_new_tab(snipper_url)
print(snippet.get_detail_for_print())
@cli.command(name='sync', help='Sync snippets with Bitbucket')
@click.argument('snippet_id', nargs=1, required=False)
@click.pass_context
def sync_snippets(ctx, **kwargs):
config = ctx.obj
colorize = config.getboolean('snipper', 'colorize')
snippet_id = kwargs.get('snippet_id')
api = SnippetApi(config)
utils.secho(colorize, ':: Waiting for download changes from Bitbucket...', fg='blue')
data = api.get_all()
utils.update_metadata(config, data)
pulling_process_count = 0
cloning_process_count = 0
snippet = None
for item in data['values']:
if snippet_id and item['id'] != snippet_id:
continue
# Show files in snippet
snippet = Snippet(config, item)
if not snippet.is_exists():
# If snippet not exist in local, clone snippet
cloning_process_count += 1
snippet.clone()
else:
# Commit changes if exist before pull new changes from remote.
snippet.commit()
snippet.sync()
snippet.update_dir_name()
pulling_process_count += 1
if snippet_id and not snippet:
utils.secho(colorize, 'Snippet with given id not found: {}'.format(snippet_id), fg='yellow')
if pulling_process_count:
utils.secho(colorize, ':: Waiting for {} processes to finish sync snippets...'.format(pulling_process_count), fg='blue')
if cloning_process_count:
utils.secho(colorize, ':: Waiting for {} processes to finish cloning snippets...'.format(cloning_process_count), fg='blue')
@cli.command(name='add', help='Add file[s] to snippet')
@click.argument('to', nargs=1, required=False)
@click.argument('files', nargs=-1, type=click.Path(exists=True))
@click.option('--filename', '-f', help='Used when content read from STDIN or clipboard')
@click.option('--open', '-o', help='Open snippet URL on browser after file added', is_flag=True)
@click.option('--paste', '-P', help='Read content from clipboard', is_flag=True)
@click.option('--copy-url', '-c', help='Copy snippet URL to clipboard', is_flag=True)
@click.pass_context
def add_to_snippet(ctx, files, **kwargs):
config = ctx.obj
colorize = config.getboolean('snipper', 'colorize')
selected_snippet_dirname = None
if not kwargs.get('to'):
utils.secho(colorize, 'Select snippet to add file with fuzzy search.', fg="yellow")
utils.secho(colorize, 'Let\'s write some text. Press Ctrl+c for quit', fg="yellow")
selected_snippet_dirname = prompt(
u'> ',
completer=SnippetDirCompleter(config),
validator=ValidateSnippetDir(config)
)
if not selected_snippet_dirname:
selected_snippet_dirname = kwargs.get('to', '')
snippet_dir_path_regex = re.search('(?:.*)?([\w]{5})$', selected_snippet_dirname)
if not snippet_dir_path_regex:
utils.secho(colorize, 'Give me path of snippet directory or snippet id', fg='red', err=True)
utils.secho(colorize, 'Existing snippet directories:', fg='blue')
_print_snippet_dirs(config, relative=True)
sys.exit(1)
snippet_id = snippet_dir_path_regex.group(1)
if not sys.stdin.isatty() and kwargs.get('paste'):
utils.secho(colorize, 'You cannot use STDIN and clipboard both for creating snippet file.', fg='red', err=True)
utils.secho(colorize, 'Please pipe content from STDIN or use -P for getting content from clipboard but not both.', fg='red', err=True)
sys.exit(1)
data = utils.read_metadata(config)
repo_parent = config.get('snipper', 'snippet_dir')
snippet = None
for item in data['values']:
if not snippet_id == item['id']:
continue
matched_path = glob.glob(os.path.join(repo_parent, '*{}'.format(snippet_id)))
if not matched_path:
utils.secho(colorize, '[{}] Snippet directory not found.'.format(snippet_id), fg='red', err=True)
sys.exit(1)
snippet_dir = matched_path[0]
snippet = Snippet(config, item)
break
if not snippet:
utils.secho(colorize, 'Snippet not found. Exiting!'.format(snippet_id), fg='red', err=True)
sys.exit(1)
# if filename is not specified, it is exist everytime as None
# so kwargs.get('filename', default_val) not working
filename = kwargs.get('filename')
if not filename:
filename = config.get('snipper', 'default_filename')
file_path = utils.get_incremented_file_path(os.path.join(snippet_dir, filename))
if not sys.stdin.isatty():
# Read from STDIN
streamed_data = sys.stdin.read()
with open(file_path, 'w') as file:
file.write(streamed_data)
utils.secho(colorize, 'File created from STDIN: {}'.format(file_path), fg='blue')
elif kwargs.get('paste'):
# Read from clipboard
clipboard_text = pyperclip.paste()
if clipboard_text:
with open(file_path, 'w') as file:
file.write(streamed_data)
utils.secho(colorize, 'File created from clipboard content: {}'.format(file_path), fg='blue')
else:
utils.secho(colorize, 'Clipboard is empty, ignoring', fg='yellow')
elif not files:
# Open editor
new_file_content = click.edit()
if new_file_content is None:
utils.secho(colorize, 'Empty content. Exiting', fg='red', err=True)
sys.exit(1)
if new_file_content == '':
confirm = click.confirm('Content is empty. Create anyway?')
if not confirm:
sys.exit(1)
with open(file_path, 'w') as file:
file.write(new_file_content)
utils.secho(colorize, 'File created: {}'.format(file_path), fg='blue')
for file in files:
# Add given files to the snippet
shutil.copy(file, snippet_dir)
utils.secho(colorize, 'File added: {}'.format(file), fg='blue')
snippet.commit()
if config.getboolean('snipper', 'auto_push'):
utils.secho(colorize, 'Snippet pushing to Bitbucket', fg='blue')
snippet.push()
def _print_snippet_dirs(config, relative=True):
colorize = config.getboolean('snipper', 'colorize')
data = utils.read_metadata(config)
for item in data['values']:
# Show files in snippet
snippet = Snippet(config, item)
path = snippet.get_slugified_dirname() if relative else snippet.get_path()
utils.secho(colorize, path, fg='yellow')
if __name__ == '__main__':
cli()