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.
@@ -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()
@@ -0,0 +1,7 @@
1
+ """Constants."""
2
+
3
+ from pypaperless.const import DOCUMENTS
4
+
5
+ GUI_PATH = {
6
+ f"{DOCUMENTS}_details": f"/{DOCUMENTS}/{{pk}}/details/",
7
+ }
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,3 @@
1
+ """Paperless-ngx CLI converter method helpers."""
2
+
3
+ from pypaperless_cli.utils.converters.helpers.strtobool import strtobool
@@ -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))