pypaperless-cli2 0.1.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.
- pypaperless_cli/__init__.py +0 -0
- pypaperless_cli/api.py +16 -0
- pypaperless_cli/app.py +186 -0
- pypaperless_cli/commands/__init__.py +6 -0
- pypaperless_cli/commands/auth.py +166 -0
- pypaperless_cli/commands/document/__init__.py +14 -0
- pypaperless_cli/commands/document/edit.py +159 -0
- pypaperless_cli/commands/document/show.py +120 -0
- pypaperless_cli/config/__init__.py +2 -0
- pypaperless_cli/config/account.py +50 -0
- pypaperless_cli/config/config.py +274 -0
- pypaperless_cli/const.py +7 -0
- pypaperless_cli/py.typed +0 -0
- pypaperless_cli/utils/__init__.py +1 -0
- pypaperless_cli/utils/converters/__init__.py +22 -0
- pypaperless_cli/utils/converters/custom_field.py +66 -0
- pypaperless_cli/utils/converters/helpers/__init__.py +3 -0
- pypaperless_cli/utils/converters/helpers/strtobool.py +19 -0
- pypaperless_cli/utils/converters/tag.py +32 -0
- pypaperless_cli/utils/groups.py +39 -0
- pypaperless_cli/utils/highlighter.py +11 -0
- pypaperless_cli/utils/types.py +27 -0
- pypaperless_cli/utils/validators/__init__.py +42 -0
- pypaperless_cli/utils/validators/custom_field.py +32 -0
- pypaperless_cli/utils/validators/document.py +27 -0
- pypaperless_cli/utils/validators/tag.py +31 -0
- pypaperless_cli2-0.1.0.dist-info/METADATA +174 -0
- pypaperless_cli2-0.1.0.dist-info/RECORD +31 -0
- pypaperless_cli2-0.1.0.dist-info/WHEEL +4 -0
- pypaperless_cli2-0.1.0.dist-info/entry_points.txt +3 -0
- pypaperless_cli2-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Store authentication information"""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from tomlkit import table
|
|
6
|
+
|
|
7
|
+
class Account():
|
|
8
|
+
"""Store a single account and its credentials."""
|
|
9
|
+
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
host: str,
|
|
13
|
+
user: Optional[str] = None,
|
|
14
|
+
token: Optional[str] = None,
|
|
15
|
+
alias: Optional[str] = None
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Instantiate account"""
|
|
18
|
+
|
|
19
|
+
# TODO: store host and protocol in separate fields to allow nicer __str__
|
|
20
|
+
self.host = host
|
|
21
|
+
self.user = user
|
|
22
|
+
self.token = token
|
|
23
|
+
self.alias = alias
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def to_toml(self) -> table:
|
|
27
|
+
"""Serializes account information into a TOML table"""
|
|
28
|
+
|
|
29
|
+
toml = table()
|
|
30
|
+
|
|
31
|
+
toml.add("host", self.host)
|
|
32
|
+
if self.user:
|
|
33
|
+
toml.add("user", self.user)
|
|
34
|
+
if self.token:
|
|
35
|
+
toml.add("token", self.token)
|
|
36
|
+
if self.alias:
|
|
37
|
+
toml.add("alias", self.alias)
|
|
38
|
+
|
|
39
|
+
return toml
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def __str__(self):
|
|
43
|
+
"""Serialize account information."""
|
|
44
|
+
|
|
45
|
+
if self.user:
|
|
46
|
+
s = f"{self.__class__}: {self.user}@{self.host} ({self.alias})"
|
|
47
|
+
else:
|
|
48
|
+
s = f"{self.__class__}: {self.host} ({self.alias})"
|
|
49
|
+
|
|
50
|
+
return s
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Handle application configuration.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from xdg_base_dirs import xdg_config_home
|
|
10
|
+
from tomlkit import (
|
|
11
|
+
dumps,
|
|
12
|
+
parse,
|
|
13
|
+
document,
|
|
14
|
+
comment,
|
|
15
|
+
nl,
|
|
16
|
+
table
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from pypaperless_cli.config.account import Account
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CLIConfig:
|
|
23
|
+
"""Parse or persist configuration"""
|
|
24
|
+
|
|
25
|
+
__accounts: List[Account] = []
|
|
26
|
+
__current_account: Account = None
|
|
27
|
+
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
"""Instantiate a CLI configuration."""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
def load(
|
|
33
|
+
self,
|
|
34
|
+
filepath: Optional[Path] = None,
|
|
35
|
+
use_account: Optional[str] = None
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Load existing configuration from file."""
|
|
38
|
+
|
|
39
|
+
if filepath is not None:
|
|
40
|
+
self.filepath = filepath
|
|
41
|
+
|
|
42
|
+
if filepath.is_file():
|
|
43
|
+
self.__parse_config(filepath.read_text())
|
|
44
|
+
|
|
45
|
+
else:
|
|
46
|
+
config_search_paths = [
|
|
47
|
+
Path.cwd().joinpath("pngx.toml"),
|
|
48
|
+
xdg_config_home().joinpath("pngx", "pngx.toml")
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
for filepath in config_search_paths:
|
|
52
|
+
self.filepath = filepath
|
|
53
|
+
|
|
54
|
+
if filepath.is_file():
|
|
55
|
+
self.__parse_config(filepath.read_text())
|
|
56
|
+
break
|
|
57
|
+
|
|
58
|
+
if use_account:
|
|
59
|
+
self.use_account(use_account)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def current(self) -> Account:
|
|
64
|
+
"""Return the default account"""
|
|
65
|
+
|
|
66
|
+
return self.__current_account
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def list(self) -> List[Account]:
|
|
70
|
+
"""Return account list"""
|
|
71
|
+
|
|
72
|
+
return self.__accounts
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_account(self, alias: str) -> None:
|
|
76
|
+
"""Return the account with the given alias"""
|
|
77
|
+
|
|
78
|
+
for account in [x for x in self.__accounts if x.alias == alias]:
|
|
79
|
+
return account
|
|
80
|
+
|
|
81
|
+
raise ValueError(f"Given alias ({alias}) does not match existing accounts.")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def use_account(self, alias: str) -> None:
|
|
86
|
+
"""Set the default account"""
|
|
87
|
+
|
|
88
|
+
self.__current_account = self.get_account(alias)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def add_account(
|
|
92
|
+
self,
|
|
93
|
+
host: str,
|
|
94
|
+
user: Optional[str] = None,
|
|
95
|
+
password: Optional[str] = None,
|
|
96
|
+
token: Optional[str] = None,
|
|
97
|
+
alias: str = "default"
|
|
98
|
+
):
|
|
99
|
+
"""Add or update an account"""
|
|
100
|
+
|
|
101
|
+
# TODO: remove any trailing slash and/or /api/* script path
|
|
102
|
+
# TODO: check if pypaperless supports unauthenticated requests or remote user auth
|
|
103
|
+
# (otherwise it doesn't make sense to support it when adding account)
|
|
104
|
+
|
|
105
|
+
# If no credentials have been provided, the API might be accessible without authentication
|
|
106
|
+
# (e.g. because a reverse proxy is adding required authentication header to the request)
|
|
107
|
+
if all([p == None for p in [user, password, token]]):
|
|
108
|
+
response = httpx.get(f"{host}/api/profile/")
|
|
109
|
+
if response.status_code != 200:
|
|
110
|
+
raise ValueError(f"Server {host} requires authentication.")
|
|
111
|
+
|
|
112
|
+
# If neither password nor API token has been specified,
|
|
113
|
+
# try Remote User authentication header
|
|
114
|
+
# TODO: Make actual header name configurable?
|
|
115
|
+
elif user != None and all([p == None for p in [password, token]]):
|
|
116
|
+
response = httpx.get(f"{host}/api/profile/", headers = {'Remote-User': user})
|
|
117
|
+
if response.status_code != 200:
|
|
118
|
+
raise ValueError(f"Server {host} requires authentication for user {user}.")
|
|
119
|
+
|
|
120
|
+
# Otherwise, request API token
|
|
121
|
+
elif password != None:
|
|
122
|
+
response = httpx.post(f"{host}/api/token/", data = {'username': user, 'password': password})
|
|
123
|
+
if response.status_code != 200:
|
|
124
|
+
raise ValueError(f"Invalid credentials for {user}@{host}.")
|
|
125
|
+
token = response.json()['token']
|
|
126
|
+
|
|
127
|
+
else:
|
|
128
|
+
response = httpx.get(f"{host}/api/profile/", headers = {'Authorization': f'Token {token}'})
|
|
129
|
+
if response.status_code != 200:
|
|
130
|
+
raise ValueError(f"Invalid token.")
|
|
131
|
+
|
|
132
|
+
# At this point, credentials have been verified
|
|
133
|
+
for i, account in enumerate(self.__accounts):
|
|
134
|
+
if account.alias == alias:
|
|
135
|
+
account.host = host
|
|
136
|
+
if user:
|
|
137
|
+
account.user = user
|
|
138
|
+
if token:
|
|
139
|
+
account.token = token
|
|
140
|
+
|
|
141
|
+
self.__accounts[i] = account
|
|
142
|
+
self.__current_account = account
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
else:
|
|
146
|
+
account = Account(
|
|
147
|
+
host = host,
|
|
148
|
+
user = user,
|
|
149
|
+
token = token,
|
|
150
|
+
alias = alias
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
self.__accounts.append(account)
|
|
154
|
+
self.__current_account = account
|
|
155
|
+
|
|
156
|
+
self.write()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def remove_account(
|
|
160
|
+
self,
|
|
161
|
+
alias: str
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Remove a named account from the configuration"""
|
|
164
|
+
|
|
165
|
+
account = self.get_account(alias)
|
|
166
|
+
|
|
167
|
+
self.__accounts.remove(account)
|
|
168
|
+
|
|
169
|
+
if self.__accounts and self.__current_account.alias == alias:
|
|
170
|
+
self.__current_account = self.__accounts[0]
|
|
171
|
+
elif not self.__accounts:
|
|
172
|
+
self.__current_account = None
|
|
173
|
+
|
|
174
|
+
self.write()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def rename_account(
|
|
178
|
+
self,
|
|
179
|
+
alias: str,
|
|
180
|
+
new_alias: str
|
|
181
|
+
) -> None:
|
|
182
|
+
"""Rename an existing account"""
|
|
183
|
+
|
|
184
|
+
if alias == new_alias:
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
for i, account in enumerate(self.__accounts):
|
|
188
|
+
if account.alias == alias:
|
|
189
|
+
account.alias = new_alias
|
|
190
|
+
self.__accounts[i] = account
|
|
191
|
+
|
|
192
|
+
if self.__current_account.alias == alias:
|
|
193
|
+
self.__current_account = account
|
|
194
|
+
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
self.write()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def __parse_config(self, content: str) -> None:
|
|
201
|
+
"""Parse configuration information.
|
|
202
|
+
|
|
203
|
+
Parameters
|
|
204
|
+
----------
|
|
205
|
+
config : str
|
|
206
|
+
The configuration that should be parsed.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
config = parse(content)
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
config.item('accounts')
|
|
213
|
+
except:
|
|
214
|
+
raise ValueError(f"Invalid configuration file.")
|
|
215
|
+
|
|
216
|
+
current_account_alias = config.item('accounts').get('current')
|
|
217
|
+
|
|
218
|
+
if not current_account_alias:
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
for k, _ in [(k, _) for k, _ in config.item('accounts').items() if k != "current"]:
|
|
222
|
+
item = config.item('accounts').get(k)
|
|
223
|
+
|
|
224
|
+
account = Account(
|
|
225
|
+
host = item.get('host'),
|
|
226
|
+
user = item.get('user'),
|
|
227
|
+
token = item.get('token'),
|
|
228
|
+
alias = item.get('alias'),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if account.alias == current_account_alias:
|
|
232
|
+
self.__current_account = account
|
|
233
|
+
|
|
234
|
+
self.__accounts.append(account)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def serialize(self) -> str:
|
|
238
|
+
"""Serialize configuration"""
|
|
239
|
+
|
|
240
|
+
content = document()
|
|
241
|
+
|
|
242
|
+
content.add(comment("Paperless-ngx CLI configuration"))
|
|
243
|
+
content.add(comment("https://github.com/marcelbrueckner/paperless-ngx-cli"))
|
|
244
|
+
content.add(nl())
|
|
245
|
+
|
|
246
|
+
accounts = table()
|
|
247
|
+
|
|
248
|
+
if self.__current_account:
|
|
249
|
+
accounts.add("current", self.__current_account.alias)
|
|
250
|
+
else:
|
|
251
|
+
accounts.add(comment("No accounts configured. Add them via `pngx auth login`."))
|
|
252
|
+
|
|
253
|
+
for account in self.__accounts:
|
|
254
|
+
accounts.add(account.alias, account.to_toml())
|
|
255
|
+
|
|
256
|
+
content.add("accounts", accounts)
|
|
257
|
+
|
|
258
|
+
return dumps(content)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def write(self) -> None:
|
|
262
|
+
"""Write configuration to disk"""
|
|
263
|
+
|
|
264
|
+
# Ad-hoc credentials should not be persisted
|
|
265
|
+
if self.__current_account and self.__current_account.alias == '__adhoc__':
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
if not self.filepath.is_file():
|
|
269
|
+
self.filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
|
|
271
|
+
self.filepath.write_text(self.serialize())
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
config = CLIConfig()
|
pypaperless_cli/const.py
ADDED
pypaperless_cli/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Paperless-ngx CLI utilities."""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Methods for type conversion"""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
from pypaperless_cli.utils.converters.custom_field import custom_field_name_to_id
|
|
7
|
+
from pypaperless_cli.utils.converters.tag import tag_name_to_id
|
|
8
|
+
|
|
9
|
+
import pypaperless_cli.utils.converters.helpers
|
|
10
|
+
|
|
11
|
+
def format_url(type_, *args) -> Any:
|
|
12
|
+
"""Default to https:// for URLs without scheme."""
|
|
13
|
+
|
|
14
|
+
value = args[0]
|
|
15
|
+
value = value.rstrip("/")
|
|
16
|
+
|
|
17
|
+
if "://" not in value:
|
|
18
|
+
return f"https://{value}"
|
|
19
|
+
else:
|
|
20
|
+
# If it's not a valid scheme,
|
|
21
|
+
# subsequent validation will catch any error
|
|
22
|
+
return value
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Custom field related converter"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from pypaperless.models.common import CustomFieldType
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pypaperless_cli.api import PaperlessAsyncAPI
|
|
10
|
+
from pypaperless_cli.utils.validators.custom_field import _validate_custom_fields
|
|
11
|
+
from pypaperless_cli.utils.converters.helpers import strtobool
|
|
12
|
+
|
|
13
|
+
async def _get_custom_field_id(name: str) -> str:
|
|
14
|
+
filters = {
|
|
15
|
+
"name__iexact": name
|
|
16
|
+
}
|
|
17
|
+
async with PaperlessAsyncAPI() as paperless:
|
|
18
|
+
async with paperless.custom_fields.reduce(**filters) as filtered:
|
|
19
|
+
async for field in filtered:
|
|
20
|
+
return field.id
|
|
21
|
+
else:
|
|
22
|
+
raise ValueError(f"Custom field \"{name}\" does not exist.")
|
|
23
|
+
|
|
24
|
+
async def _get_custom_field_type(id: int) -> CustomFieldType:
|
|
25
|
+
"""Returns CustomFieldType for a given custom field ID."""
|
|
26
|
+
|
|
27
|
+
# Method will raise an error if the ID doesn't exist
|
|
28
|
+
await _validate_custom_fields([id])
|
|
29
|
+
|
|
30
|
+
# At this point, the custom field ID can be considered valid
|
|
31
|
+
async with PaperlessAsyncAPI() as paperless:
|
|
32
|
+
field = await paperless.custom_fields(id)
|
|
33
|
+
return field.data_type
|
|
34
|
+
|
|
35
|
+
def custom_field_name_to_id(type_, *args) -> Any:
|
|
36
|
+
"""Determines ID for custom field name."""
|
|
37
|
+
|
|
38
|
+
params = []
|
|
39
|
+
|
|
40
|
+
for kv in args:
|
|
41
|
+
k, *v = kv.split("=", maxsplit=1)
|
|
42
|
+
|
|
43
|
+
if not k.isdigit():
|
|
44
|
+
k = asyncio.run(_get_custom_field_id(k))
|
|
45
|
+
|
|
46
|
+
# If no custom field value has been passed along (that is, a custom field ID or name isn't followed by an equal sign)
|
|
47
|
+
# set the value to `None` so it can be distinguished later on
|
|
48
|
+
# Ex.
|
|
49
|
+
# pngx document edit 123 --custom-fields 3=value 4= 5
|
|
50
|
+
# kv='3=value', k='3', v=['value']
|
|
51
|
+
# kv='4=', k='4', v=['']
|
|
52
|
+
# kv='5', k='5', v=[]
|
|
53
|
+
if len(v) == 0:
|
|
54
|
+
value = None
|
|
55
|
+
else:
|
|
56
|
+
value = "".join(v)
|
|
57
|
+
|
|
58
|
+
if value and asyncio.run(_get_custom_field_type(int(k))) == CustomFieldType.BOOLEAN:
|
|
59
|
+
value = strtobool(value)
|
|
60
|
+
|
|
61
|
+
params.append({
|
|
62
|
+
"id": int(k),
|
|
63
|
+
"value": value
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
return params
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Convert a string to boolean"""
|
|
2
|
+
|
|
3
|
+
# Re-implementation of deprecated distutils.util.strtobool()
|
|
4
|
+
# https://github.com/python/cpython/blob/v3.11.2/Lib/distutils/util.py#L308
|
|
5
|
+
|
|
6
|
+
def strtobool (val):
|
|
7
|
+
"""Convert a string representation of truth to true (1) or false (0).
|
|
8
|
+
|
|
9
|
+
True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
|
|
10
|
+
are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if
|
|
11
|
+
'val' is anything else.
|
|
12
|
+
"""
|
|
13
|
+
val = val.lower()
|
|
14
|
+
if val in ('y', 'yes', 't', 'true', 'on', '1'):
|
|
15
|
+
return True
|
|
16
|
+
elif val in ('n', 'no', 'f', 'false', 'off', '0'):
|
|
17
|
+
return False
|
|
18
|
+
else:
|
|
19
|
+
raise ValueError("Invalid truth value %r" % (val,))
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Tag related converter"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pypaperless_cli.api import PaperlessAsyncAPI
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def _get_tag_id(name: str) -> str:
|
|
10
|
+
filters = {
|
|
11
|
+
"name__iexact": name
|
|
12
|
+
}
|
|
13
|
+
async with PaperlessAsyncAPI() as paperless:
|
|
14
|
+
async with paperless.tags.reduce(**filters) as filtered:
|
|
15
|
+
async for tag in filtered:
|
|
16
|
+
return tag.id
|
|
17
|
+
else:
|
|
18
|
+
raise ValueError(f"Tag \"{name}\" does not exist.")
|
|
19
|
+
|
|
20
|
+
def tag_name_to_id(type_, *args) -> Any:
|
|
21
|
+
"""Determines ID for tag name."""
|
|
22
|
+
|
|
23
|
+
params = []
|
|
24
|
+
|
|
25
|
+
for k in args:
|
|
26
|
+
|
|
27
|
+
if not k.isdigit():
|
|
28
|
+
k = asyncio.run(_get_tag_id(k))
|
|
29
|
+
|
|
30
|
+
params.append(int(k))
|
|
31
|
+
|
|
32
|
+
return params
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""CLI command and parameter groups."""
|
|
2
|
+
|
|
3
|
+
from cyclopts import Group, validators
|
|
4
|
+
|
|
5
|
+
from pypaperless_cli.utils.validators import adhoc_xor_specific
|
|
6
|
+
|
|
7
|
+
#
|
|
8
|
+
# Visible groups
|
|
9
|
+
#
|
|
10
|
+
|
|
11
|
+
# Meta app
|
|
12
|
+
meta_parameters_adhoc = Group("Ad-hoc Session Parameters", sort_key=0, help="")
|
|
13
|
+
meta_parameters_specific = Group("Specific Session Parameters", sort_key=meta_parameters_adhoc.sort_key+1, help="")
|
|
14
|
+
|
|
15
|
+
# "Regular" commands group
|
|
16
|
+
# Basically cyclopt's default, but with an explicit sort_key to keep the group in upper position in the CLI's help
|
|
17
|
+
commands = Group(name = "Commands", sort_key=meta_parameters_specific.sort_key+1)
|
|
18
|
+
|
|
19
|
+
# Parameter groups
|
|
20
|
+
arguments = Group(name = "Arguments", sort_key=0)
|
|
21
|
+
standard_fields = Group(name = "Standard fields parameters", sort_key=arguments.sort_key+1)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
#
|
|
25
|
+
# Functional groups
|
|
26
|
+
#
|
|
27
|
+
|
|
28
|
+
# Mutually exclusive session parameters
|
|
29
|
+
password_xor_token = Group(
|
|
30
|
+
"Password/API Token",
|
|
31
|
+
show = False,
|
|
32
|
+
validator=validators.LimitedChoice()
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
meta_parameters = Group(
|
|
36
|
+
"Session Parameters",
|
|
37
|
+
show = False,
|
|
38
|
+
validator = adhoc_xor_specific
|
|
39
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from rich.highlighter import Highlighter
|
|
2
|
+
|
|
3
|
+
class NoneValueHighlighter(Highlighter):
|
|
4
|
+
"""Apply style to `None` value."""
|
|
5
|
+
|
|
6
|
+
def highlight(self, text):
|
|
7
|
+
"""Highlights all occurences of `None` value"""
|
|
8
|
+
|
|
9
|
+
text.highlight_regex(r"\bNone\b", "i purple")
|
|
10
|
+
|
|
11
|
+
highlight_none = NoneValueHighlighter()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Custom variable types."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from cyclopts import Parameter
|
|
6
|
+
|
|
7
|
+
from pypaperless_cli.utils import converters, validators
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
account_alias = Annotated[str, Parameter(
|
|
11
|
+
show_default = False,
|
|
12
|
+
validator = validators.starts_with_ascii_letters
|
|
13
|
+
)]
|
|
14
|
+
|
|
15
|
+
URL = Annotated[str, Parameter(
|
|
16
|
+
converter = converters.format_url,
|
|
17
|
+
validator = [validators.not_empty, validators.url]
|
|
18
|
+
)]
|
|
19
|
+
|
|
20
|
+
Document = Annotated[int, Parameter(
|
|
21
|
+
validator = validators.document_exists
|
|
22
|
+
)]
|
|
23
|
+
|
|
24
|
+
CustomFieldKeyValue = Annotated[str|int, Parameter(
|
|
25
|
+
converter = converters.custom_field_name_to_id,
|
|
26
|
+
validator = validators.custom_field_exists
|
|
27
|
+
)]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Methods for value validation."""
|
|
2
|
+
|
|
3
|
+
from string import ascii_letters
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Single value validation
|
|
8
|
+
from pypaperless_cli.utils.validators.document import document_exists
|
|
9
|
+
|
|
10
|
+
# List values validation
|
|
11
|
+
from pypaperless_cli.utils.validators.custom_field import custom_field_exists
|
|
12
|
+
from pypaperless_cli.utils.validators.tag import tag_exists
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
#
|
|
16
|
+
# Parameter validators
|
|
17
|
+
#
|
|
18
|
+
|
|
19
|
+
def not_empty(type_, value: Any) -> None:
|
|
20
|
+
if not value:
|
|
21
|
+
raise ValueError("Must not be empty.")
|
|
22
|
+
|
|
23
|
+
def starts_with_ascii_letters(type_, value: str) -> None:
|
|
24
|
+
if not value.startswith(tuple(ascii_letters)):
|
|
25
|
+
raise ValueError("Must start with a letter.")
|
|
26
|
+
|
|
27
|
+
def url(type_, value) -> None:
|
|
28
|
+
valid_protocols = ('http://', 'https://')
|
|
29
|
+
if not value.startswith(valid_protocols):
|
|
30
|
+
raise ValueError(f"Must start with {valid_protocols}.")
|
|
31
|
+
|
|
32
|
+
#
|
|
33
|
+
# Group validators
|
|
34
|
+
#
|
|
35
|
+
|
|
36
|
+
def adhoc_xor_specific(**kwargs):
|
|
37
|
+
"""Validate context selection isn't used together with any ad-hoc session parameter."""
|
|
38
|
+
|
|
39
|
+
adhoc_session_params = ["host", "user", "password", "token"]
|
|
40
|
+
|
|
41
|
+
if "use_context" in kwargs and any([True for p in kwargs.keys() if p in adhoc_session_params]):
|
|
42
|
+
raise ValueError("Context selection (--use) may not be used together with ad-hoc session parameters.")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Custom field related validators"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any, List
|
|
5
|
+
|
|
6
|
+
from pypaperless_cli.api import PaperlessAsyncAPI
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def _validate_custom_fields(field_ids: List[int]) -> None:
|
|
10
|
+
"""Validates custom fields by their IDs."""
|
|
11
|
+
|
|
12
|
+
filters = {
|
|
13
|
+
"id__in": ",".join(map(str,field_ids))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async with PaperlessAsyncAPI() as paperless:
|
|
17
|
+
async with paperless.custom_fields.reduce(**filters) as filtered:
|
|
18
|
+
existing_ids = await filtered.all()
|
|
19
|
+
|
|
20
|
+
invalid_ids = set(field_ids) - set(existing_ids)
|
|
21
|
+
|
|
22
|
+
if len(invalid_ids) == 1:
|
|
23
|
+
raise ValueError(f"Custom field with ID {', '.join(map(str,invalid_ids))} does not exist.")
|
|
24
|
+
if len(invalid_ids) > 1:
|
|
25
|
+
raise ValueError(f"Custom fields with IDs {', '.join(map(str,invalid_ids))} do not exist.")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def custom_field_exists(type_, fields: List[dict]) -> Any:
|
|
29
|
+
"""Validate custom field exists."""
|
|
30
|
+
|
|
31
|
+
field_ids = list(map(lambda f: f["id"], fields))
|
|
32
|
+
asyncio.run(_validate_custom_fields(field_ids))
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Document related validators"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pypaperless_cli.api import PaperlessAsyncAPI
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def _validate_document(id: int) -> None:
|
|
10
|
+
"""Validates a document by its ID."""
|
|
11
|
+
|
|
12
|
+
filters = {
|
|
13
|
+
"id": id
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async with PaperlessAsyncAPI() as paperless:
|
|
17
|
+
async with paperless.documents.reduce(**filters) as filtered:
|
|
18
|
+
documents = await filtered.all()
|
|
19
|
+
|
|
20
|
+
if len(documents) == 0:
|
|
21
|
+
raise ValueError(f"Document with ID {id} does not exist.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def document_exists(type_, id: int) -> Any:
|
|
25
|
+
"""Validate document exists."""
|
|
26
|
+
|
|
27
|
+
asyncio.run(_validate_document(id))
|