pdfcrowd-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pdfcrowd_cli/parser.py ADDED
@@ -0,0 +1,189 @@
1
+ # Copyright (C) 2009-2026 pdfcrowd.com
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person
4
+ # obtaining a copy of this software and associated documentation
5
+ # files (the "Software"), to deal in the Software without
6
+ # restriction, including without limitation the rights to use,
7
+ # copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the
9
+ # Software is furnished to do so, subject to the following
10
+ # conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be
13
+ # included in all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ # OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ """Canonical argument parsing and catalog-based option resolution."""
25
+
26
+ import argparse
27
+ import json
28
+ import re
29
+
30
+ from .catalog import CLIError, command_options
31
+
32
+
33
+ class Parser(argparse.ArgumentParser):
34
+ def error(self, message):
35
+ # argparse's messages may contain raw argv, URLs, or secret values.
36
+ match = re.match(r'argument ([^:]+):', message)
37
+ if match:
38
+ flags = match.group(1).split('/')
39
+ action = next((self._option_string_actions.get(flag)
40
+ for flag in flags
41
+ if flag in self._option_string_actions), None)
42
+ if action is not None:
43
+ flag = action.option_strings[0]
44
+ if 'not allowed with argument' in message:
45
+ raise CLIError(
46
+ '--config and --no-config cannot be used together.')
47
+ if action.choices:
48
+ choices = ', '.join(action.choices)
49
+ raise CLIError(f'{flag} requires one of: {choices}.')
50
+ if action.nargs == 0:
51
+ raise CLIError(f'{flag} does not take a value. '
52
+ f'Use {flag} on its own.')
53
+ raise CLIError(f'Invalid value for {flag}. '
54
+ f'Run {self.prog} help {flag}.')
55
+ raise CLIError('Unrecognized command arguments. '
56
+ f'Run {self.prog} --help or {self.prog} help syntax.')
57
+
58
+
59
+ def make_parser(converter, catalog, program=None):
60
+ parser = Parser(prog=program or converter['name'], add_help=False,
61
+ allow_abbrev=False, argument_default=argparse.SUPPRESS)
62
+ parser.add_argument('sources', nargs='*', default=[])
63
+ groups = {}
64
+ for option in command_options(converter, catalog):
65
+ if option.get('scope'):
66
+ continue
67
+ target = parser
68
+ if option.get('group'):
69
+ group = option['group']
70
+ if group not in groups:
71
+ groups[group] = parser.add_mutually_exclusive_group()
72
+ target = groups[group]
73
+ kwargs = {key: option[key] for key in (
74
+ 'action', 'default', 'choices', 'metavar') if key in option}
75
+ target.add_argument(*option['flags'], **kwargs)
76
+ for index, option in enumerate(converter['options']):
77
+ parser.add_argument(
78
+ *option['flags'], dest=f'option_{index}',
79
+ action='append' if option['repeatable'] else 'store')
80
+ return parser
81
+
82
+
83
+ def normalize(argv, converter, catalog, program=None):
84
+ program = program or converter['name']
85
+ options = {option['flags'][0]: option for option in converter['options']}
86
+ controls = {flag: option for option in command_options(converter, catalog)
87
+ for flag in option['flags']}
88
+ known = set(options) | set(controls)
89
+ removed = {
90
+ option['unsupported']['flag']: option['unsupported']['replacement']
91
+ for option in converter['options']}
92
+ result = []
93
+ index = 0
94
+ while index < len(argv):
95
+ token = argv[index]
96
+ if token == '--':
97
+ result.extend(argv[index:])
98
+ break
99
+ flag = token.split('=', 1)[0]
100
+ if flag in ('--user-name', '--api-key', '-user-name', '-api-key'):
101
+ raise CLIError('Account credentials are not accepted as options. '
102
+ f'Run {program} auth login, or set '
103
+ 'PDFCROWD_USERNAME and PDFCROWD_API_KEY.')
104
+ if flag in removed:
105
+ raise CLIError(f'Removed option {flag}. Use {removed[flag]}.')
106
+ option = options.get(flag)
107
+ control = controls.get(flag)
108
+ if control and control.get('scope'):
109
+ usage = control['usage'].format(command=program)
110
+ raise CLIError(f'{flag} applies only to {control["scope"]}. '
111
+ f'Run {usage}.')
112
+ if option and len(option['arguments']) == 1 and (
113
+ option['arguments'][0]['type'] == 'bool') and '=' not in token:
114
+ value = 'true'
115
+ if index + 1 < len(argv) and argv[index + 1].lower() in (
116
+ 'true', 'false'):
117
+ index += 1
118
+ value = argv[index].lower()
119
+ result.append(f'{flag}={value}')
120
+ elif (option or control) and '=' not in token and (
121
+ option or 'metavar' in control):
122
+ count = len(option['arguments']) if option else 1
123
+ values = []
124
+ for offset in range(1, count + 1):
125
+ if index + offset >= len(argv) or (
126
+ argv[index + offset].split('=', 1)[0] in known) or (
127
+ argv[index + offset] == '--'):
128
+ label = 'value' if count == 1 else 'values'
129
+ names = ' '.join(arg['name'].upper() for arg in (
130
+ option['arguments'])) if option else (
131
+ control['metavar'])
132
+ raise CLIError(f'{flag} requires {count} {label}: {names}. '
133
+ f'Run {program} help {flag}.')
134
+ values.append(argv[index + offset])
135
+ value = json.dumps(values) if count > 1 else values[0]
136
+ canonical = option['flags'][0] if option else control['flags'][0]
137
+ result.append(f'{canonical}={value}')
138
+ index += count
139
+ else:
140
+ result.append(token)
141
+ index += 1
142
+ return result
143
+
144
+
145
+ def option_value(option, value, command):
146
+ count = len(option['arguments'])
147
+ values = [value]
148
+ if count > 1:
149
+ try:
150
+ # JSON arrays preserve literal separators in saved settings.
151
+ # Existing sectionless configurations may still use semicolons.
152
+ values = json.loads(value) if value.startswith('[') else (
153
+ value.split(';'))
154
+ except ValueError:
155
+ values = None
156
+ if not isinstance(values, list) or len(values) != count or any(
157
+ not isinstance(part, str) for part in values):
158
+ raise CLIError(f'--{option["name"]} requires {count} values. '
159
+ f'Run {command} help {option["name"]}.')
160
+ resolved = []
161
+ for definition, part in zip(option['arguments'], values):
162
+ kind = definition['type']
163
+ try:
164
+ if kind == 'bool':
165
+ if part.lower() not in ('true', 'false'):
166
+ raise ValueError()
167
+ part = part.lower() == 'true'
168
+ elif kind == 'int':
169
+ part = int(part)
170
+ except ValueError:
171
+ expected = 'true or false' if kind == 'bool' else 'a whole number'
172
+ raise CLIError(f'Invalid value for --{option["name"]}. '
173
+ f'Use {expected}. Run {command} help '
174
+ f'{option["name"]}.') from None
175
+ resolved.append(part)
176
+ return resolved
177
+
178
+
179
+ def invocation_settings(namespace, converter, program=None):
180
+ values = vars(namespace)
181
+ result = {}
182
+ for index, option in enumerate(converter['options']):
183
+ if f'option_{index}' not in values:
184
+ continue
185
+ value = values[f'option_{index}']
186
+ for item in value if option['repeatable'] else [value]:
187
+ option_value(option, item, program or converter['name'])
188
+ result[option['name']] = value
189
+ return result
pdfcrowd_cli/state.py ADDED
@@ -0,0 +1,368 @@
1
+ # Copyright (C) 2009-2026 pdfcrowd.com
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person
4
+ # obtaining a copy of this software and associated documentation
5
+ # files (the "Software"), to deal in the Software without
6
+ # restriction, including without limitation the rights to use,
7
+ # copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ # copies of the Software, and to permit persons to whom the
9
+ # Software is furnished to do so, subject to the following
10
+ # conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be
13
+ # included in all copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17
+ # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19
+ # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20
+ # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21
+ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ # OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ """Explicit converter defaults and separately protected account storage."""
25
+
26
+ import configparser
27
+ import getpass
28
+ import json
29
+ import os
30
+ from pathlib import Path
31
+ import stat
32
+ import sys
33
+ import tempfile
34
+
35
+ from .catalog import CLIError
36
+ from .parser import option_value
37
+
38
+
39
+ def paths(command):
40
+ from platformdirs import user_config_dir
41
+ directory = Path(user_config_dir('pdfcrowd', appauthor=False)) / 'cli'
42
+ return directory / f'{command}.ini', directory / 'credentials.json'
43
+
44
+
45
+ def configuration(namespace, converter, program=None):
46
+ program = program or converter['name']
47
+ default, _ = paths(converter['name'])
48
+ selected = Path(getattr(namespace, 'config', default))
49
+ if namespace.no_config:
50
+ return selected, {}
51
+ try:
52
+ text = selected.read_text(encoding='utf-8')
53
+ except FileNotFoundError:
54
+ if not hasattr(namespace, 'config'):
55
+ return selected, {}
56
+ raise CLIError('The selected config file does not exist. '
57
+ 'Choose an existing file with --config PATH, or create '
58
+ 'one with --init-config --config PATH.') from None
59
+ except (OSError, UnicodeError):
60
+ raise CLIError('Could not read the config file. Check its permissions '
61
+ 'and save it as UTF-8 text.') from None
62
+ parser = configparser.ConfigParser(interpolation=None, strict=True,
63
+ empty_lines_in_values=False)
64
+ try:
65
+ parser.read_string('[converter]\n' + text)
66
+ if parser.sections() != ['converter']:
67
+ raise ValueError()
68
+ values = dict(parser['converter'])
69
+ except (configparser.Error, ValueError):
70
+ raise CLIError(
71
+ 'Invalid config file. Use option-name = value lines without '
72
+ f'[section] headings. Run {program} help '
73
+ 'configuration for examples.') from None
74
+ options = {option['name']: option for option in converter['options']}
75
+ result = {}
76
+ for name, value in values.items():
77
+ option = options.get(name)
78
+ if option is None:
79
+ raise CLIError('The config file contains an unknown setting. '
80
+ 'Use option names without leading dashes. '
81
+ f'Run {program} help configuration.')
82
+ if not option['configuration']:
83
+ raise CLIError(f'--{option["name"]} contains sensitive data '
84
+ 'and cannot be saved in a conversion config file. '
85
+ 'Pass this option on the command line.')
86
+ if option['repeatable']:
87
+ value = [line.strip() for line in value.splitlines()
88
+ if line.strip()]
89
+ for item in value if isinstance(value, list) else [value]:
90
+ option_value(option, item, program)
91
+ result[name] = value
92
+ return selected, result
93
+
94
+
95
+ def expand_text(converter, settings, origins, config_path):
96
+ result = dict(settings)
97
+ for option in converter['options']:
98
+ name = option['name']
99
+ if not option['text_file'] or name not in result:
100
+ continue
101
+ value = result[name]
102
+ if not isinstance(value, str) or not (
103
+ value.startswith('@{') and value.endswith('}')):
104
+ continue
105
+ reference = Path(value[2:-1])
106
+ if not reference.is_absolute() and origins[name] == 'configuration':
107
+ reference = config_path.parent / reference
108
+ try:
109
+ result[name] = reference.read_bytes().decode('utf-8')
110
+ except UnicodeDecodeError:
111
+ raise CLIError(f'Cannot read file "{reference}" for --{name}: '
112
+ 'the file is not encoded as UTF-8.') from None
113
+ except OSError as error:
114
+ reason = error.strerror or str(error)
115
+ raise CLIError(f'Cannot read file "{reference}" for --{name}: '
116
+ f'{reason}.') from None
117
+ return result
118
+
119
+
120
+ def inspect_settings(converter, settings, origins, config_path,
121
+ no_config=False):
122
+ status = ('ignored by --no-config' if no_config else
123
+ 'loaded' if config_path.exists() else 'not created')
124
+ lines = [f'Config file: {config_path} ({status})', '',
125
+ 'Conversion settings from the config file and command line.',
126
+ 'Other options use their API defaults.', '']
127
+ if not settings:
128
+ return '\n'.join(lines + ['No conversion settings configured.']) + '\n'
129
+ for option in converter['options']:
130
+ name = option['name']
131
+ if name not in settings:
132
+ continue
133
+ value = settings[name]
134
+ if option['sensitive']:
135
+ value = '[hidden: sensitive value]'
136
+ elif option['text_file'] and not (
137
+ isinstance(value, str) and value.startswith('@{') and
138
+ value.endswith('}')):
139
+ value = '[hidden: inline text]'
140
+ elif isinstance(value, list):
141
+ value = json.dumps(value)
142
+ elif '\n' in value or '\r' in value:
143
+ value = json.dumps(value)
144
+ origin = ('command line' if origins[name] == 'invocation' else
145
+ 'config file')
146
+ lines.append(f'--{name}: {value} ({origin})')
147
+ return '\n'.join(lines) + '\n'
148
+
149
+
150
+ def init_configuration(namespace, converter):
151
+ default, _ = paths(converter['name'])
152
+ target = Path(getattr(namespace, 'config', default))
153
+ if namespace.no_config:
154
+ raise CLIError('--init-config cannot be combined with --no-config.')
155
+ lines = [
156
+ '# PDFCrowd converter defaults; no sections are needed.',
157
+ '# Remove a comment marker to enable a setting.',
158
+ '# Command-line options override these defaults.',
159
+ '# Conversion commands do not automatically save settings here.',
160
+ '# Text-content settings accept @{PATH}; '
161
+ 'paths are relative to this file.',
162
+ f'# Reference: {converter["reference_url"]}', '', '# Common options',
163
+ ]
164
+ for name in ('page-size', 'orientation', 'output-format', 'debug-log'):
165
+ for option in converter['options']:
166
+ if option['name'] == name:
167
+ default_value = option['arguments'][0]['default']
168
+ if default_value is not None:
169
+ lines.append(f'# {name} = {default_value}')
170
+ try:
171
+ target.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
172
+ with target.open('x', encoding='utf-8') as output:
173
+ output.write('\n'.join(lines) + '\n')
174
+ except FileExistsError:
175
+ raise CLIError('The config file already exists and was left unchanged. '
176
+ 'Edit it directly, or use --config PATH to choose '
177
+ 'another file.')
178
+ except OSError:
179
+ raise CLIError(
180
+ 'Could not create configuration. Check directory access.')
181
+ return (f'Created config file: {target}\n'
182
+ 'Open this file and remove the leading # from settings you want '
183
+ 'to use.\n'
184
+ 'The default file is loaded automatically. For a custom file, '
185
+ 'pass --config PATH when converting.\n')
186
+
187
+
188
+ def validate_pair(username, key):
189
+ if not all(isinstance(value, str) and value and '\n' not in value and
190
+ '\r' not in value for value in (username, key)):
191
+ raise CLIError('Supply a complete username and API-key pair.', 3)
192
+ return username, key
193
+
194
+
195
+ def environment_pair():
196
+ names = ('PDFCROWD_USERNAME', 'PDFCROWD_API_KEY')
197
+ if not any(name in os.environ for name in names):
198
+ return None
199
+ try:
200
+ return validate_pair(*(os.environ.get(name) for name in names))
201
+ except CLIError:
202
+ raise CLIError('Set both PDFCROWD_USERNAME and PDFCROWD_API_KEY. '
203
+ 'Clear both to use saved credentials or the demo '
204
+ 'account.', 3) from None
205
+
206
+
207
+ def check_protection(path, metadata):
208
+ if os.name == 'nt':
209
+ from .windows import check_private
210
+ check_private(path)
211
+ elif metadata.st_uid != os.getuid() or metadata.st_mode & 0o077:
212
+ raise CLIError('Credential file or directory is not private. '
213
+ 'It must belong to you. Set file permissions to 600 '
214
+ 'and directory permissions to 700.', 3)
215
+
216
+
217
+ def file_pair(path):
218
+ try:
219
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0))
220
+ except FileNotFoundError:
221
+ return None
222
+ except OSError:
223
+ raise CLIError('Could not read saved credentials.', 3) from None
224
+ with os.fdopen(descriptor, 'r', encoding='utf-8') as source:
225
+ if path.is_symlink() or path.parent.is_symlink():
226
+ raise CLIError('Credential storage must not use symlinks.', 3)
227
+ check_protection(path.parent, path.parent.lstat())
228
+ metadata = os.fstat(source.fileno())
229
+ if not stat.S_ISREG(metadata.st_mode):
230
+ raise CLIError('Credential storage must be a regular file.', 3)
231
+ check_protection(path, metadata)
232
+ try:
233
+ pair = json.load(source)
234
+ if not isinstance(pair, dict):
235
+ raise ValueError()
236
+ return validate_pair(pair.get('username'), pair.get('api_key'))
237
+ except (ValueError, UnicodeError, CLIError):
238
+ raise CLIError('Saved credentials are invalid. '
239
+ 'Run pdfcrowd auth login to save your account '
240
+ 'again.', 3) from None
241
+
242
+
243
+ def select_credentials(command):
244
+ _, path = paths(command)
245
+ pair = environment_pair()
246
+ source = 'environment'
247
+ if pair is None:
248
+ pair = file_pair(path)
249
+ source = 'credentials_file'
250
+ if pair is None or pair == ('demo', 'demo'):
251
+ return ('demo', 'demo'), 'demo'
252
+ return pair, source
253
+
254
+
255
+ def save_pair(path, pair):
256
+ temporary = None
257
+ try:
258
+ path.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
259
+ if path.parent.is_symlink():
260
+ raise CLIError('Credential directory must not be a symlink.', 3)
261
+ if os.name == 'nt':
262
+ from .windows import protect
263
+ protect(path.parent)
264
+ check_protection(path.parent, path.parent.stat())
265
+ descriptor, temporary = tempfile.mkstemp(dir=path.parent)
266
+ with os.fdopen(descriptor, 'w', encoding='utf-8') as output:
267
+ if os.name == 'nt':
268
+ protect(Path(temporary))
269
+ output.write(json.dumps({'username': pair[0], 'api_key': pair[1]})
270
+ + '\n')
271
+ os.replace(temporary, path)
272
+ temporary = None
273
+ except OSError:
274
+ raise CLIError('Could not save protected credentials. '
275
+ 'Previous credentials were retained.', 3) from None
276
+ finally:
277
+ if temporary is not None:
278
+ try:
279
+ os.unlink(temporary)
280
+ except OSError:
281
+ pass
282
+
283
+
284
+ def authentication(command, arguments, catalog, program=None):
285
+ program = program or command
286
+ usage = (f'Usage: {program} auth ACTION\n\n'
287
+ 'Commands\n'
288
+ ' login [--non-interactive] Save your username and API key\n'
289
+ ' status Show which credentials will be used\n'
290
+ ' logout Remove saved credentials\n\n'
291
+ 'Credentials are shared by all converter commands.\n'
292
+ f'More help: {program} auth login --help\n')
293
+ if not arguments:
294
+ return usage
295
+ action = arguments[0]
296
+ extra = arguments[1:]
297
+ if any(flag in arguments for flag in ('--help', '-h')):
298
+ if action not in ('login', 'status', 'logout'):
299
+ return usage
300
+ auth = catalog['authentication']
301
+ scope = ' [--non-interactive]' if action == 'login' else ''
302
+ _, path = paths(command)
303
+ descriptions = {
304
+ 'login': (
305
+ 'Save your PDFCrowd username and API key for all converters.\n'
306
+ 'The API-key prompt hides your input.\n\n'
307
+ 'For --non-interactive, set both PDFCROWD_USERNAME and\n'
308
+ 'PDFCROWD_API_KEY. Missing credentials cause an error.\n'
309
+ f'{auth["hint"]}\nAPI key: {auth["api_key_url"]}\n'),
310
+ 'status': (
311
+ 'Show which credentials will be used: environment\n'
312
+ 'variables, saved credentials, or the demo account.\n'
313
+ 'This reads local settings; it does not verify the account\n'
314
+ 'with PDFCrowd.\n'),
315
+ 'logout': (
316
+ 'Remove the saved account for all converter commands.\n'
317
+ 'PDFCROWD_USERNAME and PDFCROWD_API_KEY still apply if set.\n'),
318
+ }
319
+ return (f'Usage: {program} auth {action}{scope}\n\n'
320
+ f'{descriptions[action]}\nShared credentials file: {path}\n')
321
+ if action not in ('login', 'status', 'logout'):
322
+ raise CLIError('Unknown authentication action. '
323
+ f'Run {program} auth --help.')
324
+ if extra and extra != ['--non-interactive']:
325
+ raise CLIError('Invalid authentication arguments. '
326
+ f'Run {program} auth {action} --help.')
327
+ if extra == ['--non-interactive'] and action != 'login':
328
+ raise CLIError('--non-interactive applies only to auth login.')
329
+ _, path = paths(command)
330
+ if action == 'logout':
331
+ try:
332
+ path.unlink()
333
+ except FileNotFoundError:
334
+ return ('No saved credentials to remove.\n'
335
+ 'Credential environment variables still apply if set.\n')
336
+ except OSError:
337
+ raise CLIError('Could not remove saved credentials.', 3)
338
+ return ('Saved credentials removed for all converter commands.\n'
339
+ 'Credential environment variables still apply if set.\n')
340
+ if action == 'status':
341
+ pair, source = select_credentials(command)
342
+ labels = {'environment': 'environment variables',
343
+ 'credentials_file': 'saved credentials',
344
+ 'demo': 'demo account'}
345
+ result = f'Account source: {labels[source]}\nUsername: {pair[0]}\n'
346
+ if source == 'credentials_file':
347
+ result += f'Credentials file: {path}\n'
348
+ if source == 'demo':
349
+ result += catalog['converters'][command]['demo_notice'] + '\n'
350
+ return (result + 'This status check does not verify the account '
351
+ 'with PDFCrowd.\n')
352
+ pair = environment_pair()
353
+ if pair is None:
354
+ if extra == ['--non-interactive'] or not sys.stdin.isatty():
355
+ raise CLIError('Login without prompts requires PDFCROWD_USERNAME '
356
+ 'and PDFCROWD_API_KEY. Set both, or run '
357
+ f'{program} auth login in a terminal.', 3)
358
+ auth = catalog['authentication']
359
+ print(f'Save your PDFCrowd account for all converter commands.\n'
360
+ f'API key: {auth["api_key_url"]}\n{auth["hint"]}',
361
+ file=sys.stderr)
362
+ pair = validate_pair(input('Username: '), getpass.getpass('API key: '))
363
+ if pair == ('demo', 'demo'):
364
+ raise CLIError('Login requires personal account credentials.', 3)
365
+ save_pair(path, pair)
366
+ return (f'Saved account for all converter commands.\n'
367
+ f'Credentials file: {path}\n'
368
+ 'The account will be verified during conversion.\n')