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/__init__.py +24 -0
- pdfcrowd_cli/__main__.py +28 -0
- pdfcrowd_cli/adapter.py +143 -0
- pdfcrowd_cli/catalog.py +46 -0
- pdfcrowd_cli/command-spec.json +16717 -0
- pdfcrowd_cli/entrypoints.py +228 -0
- pdfcrowd_cli/help.py +385 -0
- pdfcrowd_cli/parser.py +189 -0
- pdfcrowd_cli/state.py +368 -0
- pdfcrowd_cli/windows.py +173 -0
- pdfcrowd_cli-1.0.0.dist-info/LICENSE +22 -0
- pdfcrowd_cli-1.0.0.dist-info/METADATA +112 -0
- pdfcrowd_cli-1.0.0.dist-info/RECORD +16 -0
- pdfcrowd_cli-1.0.0.dist-info/WHEEL +5 -0
- pdfcrowd_cli-1.0.0.dist-info/entry_points.txt +10 -0
- pdfcrowd_cli-1.0.0.dist-info/top_level.txt +1 -0
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')
|