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.
File without changes
pypaperless_cli/api.py ADDED
@@ -0,0 +1,16 @@
1
+ """Paperless API client"""
2
+
3
+ from aiohttp import ClientSession
4
+ from pypaperless import Paperless
5
+
6
+ from pypaperless_cli.config import config as appconfig
7
+
8
+ class PaperlessAsyncAPI(Paperless):
9
+ """Represent the Paperless API"""
10
+
11
+ def __init__(self):
12
+ session = ClientSession(headers={"User-Agent": f"pypaperless-cli/0.1-dev (https://github.com/marcelbrueckner/paperless-ngx-cli)"})
13
+ super().__init__(appconfig.current.host, appconfig.current.token, session=session)
14
+
15
+ # Don't care about warnings
16
+ self.logger.setLevel("ERROR")
pypaperless_cli/app.py ADDED
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env python
2
+
3
+ import sys
4
+ from typing import Annotated, Optional
5
+
6
+ from cyclopts import App, Parameter
7
+ from cyclopts.types import Path
8
+ from cyclopts.exceptions import format_cyclopts_error
9
+
10
+ from rich.prompt import Prompt
11
+ from rich.console import Console
12
+
13
+ from pypaperless_cli.config import config as appconfig
14
+ from pypaperless_cli.utils import groups, validators
15
+ from pypaperless_cli.commands import (
16
+ auth,
17
+ document,
18
+ )
19
+ from pypaperless_cli.utils.types import (
20
+ account_alias,
21
+ URL
22
+ )
23
+
24
+
25
+ # BASIC APP STRUCTURE
26
+ # Loosely based on the Paperless-ngx API and web interface structure
27
+ # https://cyclopts.readthedocs.io/en/latest/commands.html
28
+ # https://docs.paperless-ngx.com/api/
29
+
30
+ app = App(
31
+ name="pngx",
32
+ help="Command-line interface for Paperless-ngx 🌱",
33
+ group_commands=groups.commands,
34
+ version_flags=["--version", "-v"]
35
+ )
36
+
37
+ # Change the group of "--help" and "--version" to the implicit "Help" group.
38
+ app["--help"].group = "Help"
39
+ app["--version"].group = "Help"
40
+
41
+ app.command(auth)
42
+ app.command(document)
43
+
44
+
45
+ #
46
+ # CLI HELP
47
+ #
48
+
49
+ app.meta["--help"].group = "Help"
50
+ app.meta["--version"].group = "Help"
51
+
52
+
53
+ #
54
+ # CLI entry-point
55
+ #
56
+
57
+ # Set up configuration before running the actual application
58
+ @app.meta.default()
59
+ def main(
60
+ *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
61
+ host: Annotated[Optional[URL], Parameter(
62
+ env_var=['PNGX_HOST'],
63
+ group = [groups.meta_parameters, groups.meta_parameters_adhoc],
64
+ )] = None,
65
+ user: Annotated[Optional[str], Parameter(
66
+ env_var = ['PNGX_USER'],
67
+ group = [groups.meta_parameters, groups.meta_parameters_adhoc],
68
+ validator = validators.not_empty,
69
+ )] = None,
70
+ password: Annotated[Optional[str], Parameter(
71
+ env_var = ['PNGX_PASSWORD'],
72
+ negative = "--ask-password",
73
+ group = [groups.meta_parameters, groups.meta_parameters_adhoc, groups.password_xor_token]
74
+ )] = None,
75
+ ask_password: Annotated[Optional[bool], Parameter(
76
+ show = False,
77
+ group = [groups.meta_parameters, groups.meta_parameters_adhoc, groups.password_xor_token]
78
+ )] = None,
79
+ token: Annotated[Optional[str], Parameter(
80
+ env_var = ['PNGX_TOKEN'],
81
+ negative = "--ask-token",
82
+ group = [groups.meta_parameters, groups.meta_parameters_adhoc, groups.password_xor_token],
83
+ )] = None,
84
+ ask_token: Annotated[Optional[bool], Parameter(
85
+ show = False,
86
+ group = [groups.meta_parameters, groups.meta_parameters_adhoc, groups.password_xor_token]
87
+ )] = None,
88
+ config_file: Annotated[Optional[Path], Parameter(
89
+ name = "--config",
90
+ env_var = ['PNGX_CONFIG'],
91
+ group = [groups.meta_parameters, groups.meta_parameters_specific]
92
+ )] = None,
93
+ use_account: Annotated[Optional[account_alias], Parameter(
94
+ name = "--use",
95
+ group = [groups.meta_parameters, groups.meta_parameters_specific],
96
+ validator = validators.starts_with_ascii_letters
97
+ )] = None,
98
+ show_config: Annotated[Optional[bool], Parameter(
99
+ group = [groups.meta_parameters, "Help"],
100
+ negative = [],
101
+ show_default = False
102
+ )] = False,
103
+ ) -> None:
104
+
105
+ """Initiate CLI
106
+
107
+ Parameters
108
+ ----------
109
+ host: str
110
+ The URL of your Paperless-ngx host, possibly including a custom port and/or script path.
111
+ user: str
112
+ Username
113
+ password: str
114
+ Password. Will be used to request an API token only.
115
+ token: str
116
+ API token.
117
+ config_file: Path
118
+ Path to configuration file.
119
+ use_account: str
120
+ Name (alias) of an account that should be used.
121
+
122
+ If an account with the given alias exists, its credentials will be re-used.
123
+ If not specified, the default account will be used (if any).
124
+ show_config: bool
125
+ Show path of the configuration file in use.
126
+ """
127
+
128
+
129
+ if ask_password:
130
+ password = Prompt.ask("What's your password?", password=True)
131
+
132
+ elif ask_token:
133
+ token = Prompt.ask("What's your API token?", password=True)
134
+
135
+ # Parse configuration
136
+ try:
137
+ appconfig.load(config_file, use_account)
138
+ except ValueError as e:
139
+ Console().print(format_cyclopts_error(e))
140
+ sys.exit(1)
141
+
142
+ if show_config:
143
+ print(appconfig.filepath.absolute())
144
+ sys.exit(0)
145
+
146
+ # Add ad-hoc configuration
147
+ if host and not tokens[:2] == ('auth', 'login'):
148
+ try:
149
+ appconfig.add_account(
150
+ host = host,
151
+ user = user,
152
+ password = password,
153
+ token = token,
154
+ alias = "__adhoc__"
155
+ )
156
+ except ValueError as e:
157
+ Console().print(format_cyclopts_error(e))
158
+ sys.exit(1)
159
+
160
+ elif tokens[:2] == ('auth', 'login'):
161
+ # Pass credentials to login function
162
+ if host:
163
+ tokens += (host,)
164
+ if user:
165
+ tokens += ("--user", user)
166
+ if password:
167
+ tokens += ("--password", password)
168
+ if token:
169
+ tokens += ("--token", token)
170
+
171
+ elif not appconfig.list():
172
+ Console().print(format_cyclopts_error("No accounts configured that can be used."))
173
+ sys.exit(1)
174
+
175
+ # Now run the actual app
176
+ try:
177
+ app(tokens)
178
+ except ValueError as e:
179
+ Console().print(format_cyclopts_error(e))
180
+ sys.exit(1)
181
+
182
+
183
+ def launch() -> None:
184
+ """Run commands."""
185
+
186
+ app.meta()
@@ -0,0 +1,6 @@
1
+ """
2
+ Exports for CLI commands.
3
+ """
4
+
5
+ from pypaperless_cli.commands.auth import auth
6
+ from pypaperless_cli.commands.document import document
@@ -0,0 +1,166 @@
1
+ """
2
+ Command to manage authentication information.
3
+ """
4
+
5
+ import sys
6
+ from typing import Annotated, Optional
7
+
8
+ from cyclopts import App, Parameter
9
+ from cyclopts.exceptions import format_cyclopts_error
10
+
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+ from rich import box
14
+
15
+ from pypaperless_cli.utils.types import (
16
+ account_alias,
17
+ URL
18
+ )
19
+ from pypaperless_cli.config import config as appconfig
20
+
21
+ #
22
+ # Authentication
23
+ #
24
+
25
+ auth = App(name="auth", help="Manage authentication information", version_flags=[])
26
+ auth["--help"].group = "Help"
27
+
28
+ @auth.command
29
+ def login(
30
+ host: Annotated[URL, Parameter(env_var=['PNGX_HOST'])],
31
+ /, *,
32
+ user: Annotated[Optional[str], Parameter(env_var=['PNGX_USER'])] = None,
33
+ password: Annotated[Optional[str], Parameter(env_var=['PNGX_PASSWORD'],negative="--ask-password")] = None,
34
+ token: Annotated[Optional[str], Parameter(env_var=['PNGX_TOKEN'],negative="--ask-token")] = None,
35
+ alias: Optional[account_alias] = "default"
36
+ ) -> None:
37
+
38
+ """Log in to your instance of Paperless-ngx.
39
+
40
+ Examples
41
+ --------
42
+ pngx login https://paperless.example.com --user USERNAME --token TOKEN
43
+
44
+ Parameters
45
+ ----------
46
+ host: str
47
+ The URL of your Paperless-ngx host, possibly including a custom port and/or script path.
48
+ user: str
49
+ Username
50
+ alias: str
51
+ Assign the credentials provided to an account named according to the given alias.
52
+ Allows to easily switch between multiple Paperless-ngx hosts (e.g. personal, business) on subsequent commands.
53
+
54
+ If not specified, an implicit default account will be created.
55
+
56
+ :warning: Providing an existing account alias will overwrite existing credentials.
57
+ """
58
+
59
+ # Add credentials
60
+ try:
61
+ appconfig.add_account(
62
+ host = host,
63
+ user = user,
64
+ password = password,
65
+ token = token,
66
+ alias = alias
67
+ )
68
+ except ValueError as e:
69
+ Console().print(format_cyclopts_error(e))
70
+ sys.exit(1)
71
+
72
+
73
+ @auth.command
74
+ def logout(alias: account_alias = None, /) -> None:
75
+ """Remove credentials from disk for the given account.
76
+
77
+ Parameters
78
+ ----------
79
+ alias: account_alias
80
+ Name of the account to be logged out from. Defaults to the account currently in use.
81
+ """
82
+
83
+ if alias:
84
+ appconfig.remove_account(alias)
85
+ else:
86
+ appconfig.remove_account(appconfig.current.alias)
87
+
88
+
89
+ @auth.command
90
+ def use(alias: account_alias, /) -> None:
91
+ """Set the current account
92
+
93
+ Parameters
94
+ ----------
95
+ alias: account_alias
96
+ Name of an existing account to be set as the default account.
97
+ """
98
+
99
+ appconfig.use_account(alias)
100
+ appconfig.write()
101
+
102
+
103
+ @auth.command
104
+ def show(alias: account_alias = None, /) -> None:
105
+ """Show details of an existing account.
106
+
107
+ Parameters
108
+ ----------
109
+ alias: account_alias
110
+ Name of an existing account. Defaults to the current account if no alias is given.
111
+ """
112
+
113
+ accounts = appconfig.list()
114
+
115
+ if accounts:
116
+ table = Table(box=box.SIMPLE_HEAD)
117
+ table.add_column("Alias")
118
+ table.add_column("Host")
119
+ table.add_column("User")
120
+
121
+ if not alias:
122
+ table.add_row(appconfig.current.alias, appconfig.current.host, appconfig.current.user)
123
+ else:
124
+ account = appconfig.get_account(alias)
125
+ table.add_row(account.alias, account.host, account.user)
126
+
127
+ Console().print(table)
128
+
129
+
130
+ @auth.command
131
+ def list() -> None:
132
+ """List available accounts"""
133
+
134
+ accounts = appconfig.list()
135
+
136
+ if accounts:
137
+ table = Table(box=box.SIMPLE_HEAD)
138
+ table.add_column("Alias")
139
+ table.add_column("Host")
140
+ table.add_column("User")
141
+
142
+ for account in accounts:
143
+ if account.alias == appconfig.current.alias:
144
+ table.add_row(f"* {account.alias}", account.host, account.user, style="green")
145
+ else:
146
+ table.add_row(f" {account.alias}", account.host, account.user)
147
+
148
+ Console().print(table)
149
+
150
+ else:
151
+ Console().print("No accounts configured.")
152
+
153
+
154
+ @auth.command
155
+ def rename(alias: account_alias, new_alias: account_alias, /) -> None:
156
+ """Rename an existing account.
157
+
158
+ Parameters
159
+ ----------
160
+ alias: account_alias
161
+ Name of an existing account that should be renamed.
162
+ new_alias: account_alias
163
+ New name of the account.
164
+ """
165
+
166
+ appconfig.rename_account(alias, new_alias)
@@ -0,0 +1,14 @@
1
+ """Command to manage documents."""
2
+
3
+ from cyclopts import App
4
+
5
+ from pypaperless_cli.utils import groups
6
+
7
+ from pypaperless_cli.commands.document.show import show
8
+ from pypaperless_cli.commands.document.edit import edit
9
+
10
+ document = App(name="document", help="Work with your documents.", group_commands=groups.commands, version_flags=[])
11
+ document["--help"].group = "Help"
12
+
13
+ document.command(show)
14
+ document.command(edit, group_arguments=groups.arguments, group_parameters=groups.standard_fields)
@@ -0,0 +1,159 @@
1
+ """Method for editing documents."""
2
+
3
+ from typing import Annotated, List, Optional
4
+
5
+ from cyclopts import Group, Parameter
6
+
7
+ from pypaperless.models.common import CustomFieldValue
8
+
9
+ from pypaperless_cli.api import PaperlessAsyncAPI
10
+ from pypaperless_cli.utils import converters, groups, validators
11
+ from pypaperless_cli.utils.types import CustomFieldKeyValue, Document
12
+
13
+ group_tags = Group(name = "Tags parameters", sort_key=groups.standard_fields.sort_key+1)
14
+ group_custom_fields = Group(name = "Custom fields parameters", sort_key=group_tags.sort_key+1)
15
+
16
+ async def edit(
17
+ id: Document,
18
+ /, *,
19
+ asn: Optional[int] = None,
20
+ correspondent: Optional[int] = None,
21
+ document_type: Optional[int] = None,
22
+ storage_path: Optional[int] = None,
23
+ title: Optional[str] = None,
24
+ created_date: Optional[str] = None,
25
+
26
+ # Handle tags
27
+ add_tags: Annotated[
28
+ Optional[List[str|int]],
29
+ Parameter(
30
+ name = ["--tags", "--add-tags"],
31
+ negative = [],
32
+ group = group_tags,
33
+ # Assigning converter/validator to custom type doesn't work with the current version of Cyclopts,
34
+ # thus explicitly adding it to parameter
35
+ converter = converters.tag_name_to_id,
36
+ validator = validators.tag_exists
37
+ )] = None,
38
+ remove_tags: Annotated[
39
+ Optional[List[str|int]],
40
+ Parameter(
41
+ negative = [],
42
+ group = group_tags,
43
+ # Assigning converter/validator to custom type doesn't work with the current version of Cyclopts,
44
+ # thus explicitly adding it to parameter
45
+ converter = converters.tag_name_to_id,
46
+ validator = validators.tag_exists
47
+ )] = None,
48
+
49
+ add_custom_fields: Annotated[
50
+ Optional[List[CustomFieldKeyValue]],
51
+ Parameter(
52
+ name = ["--custom-fields", "--add-custom-fields"],
53
+ negative = [],
54
+ group = group_custom_fields,
55
+ # Assigning converter/validator to custom type doesn't work with the current version of Cyclopts,
56
+ # thus explicitly adding it to parameter
57
+ converter = converters.custom_field_name_to_id,
58
+ validator = validators.custom_field_exists
59
+ )] = None,
60
+ remove_custom_fields: Annotated[
61
+ Optional[List[CustomFieldKeyValue]],
62
+ Parameter(
63
+ negative = [],
64
+ group = group_custom_fields,
65
+ # Assigning converter/validator to custom type doesn't work with the current version of Cyclopts,
66
+ # thus explicitly adding it to parameter
67
+ converter = converters.custom_field_name_to_id,
68
+ validator = validators.custom_field_exists
69
+ )] = None
70
+ ) -> None:
71
+
72
+ """Update a document's information.
73
+
74
+ Parameters
75
+ ----------
76
+ id: int
77
+ The ID of the document to be updated.
78
+ asn: int
79
+ Archive serial number. The unique identifier of the document in your physical document binders.
80
+ correspondent: int
81
+ ID of the correspondent.
82
+ document_type: int
83
+ ID of the document type.
84
+ storage_path: int
85
+ ID of the storage path.
86
+ title: str
87
+ Document title
88
+ created_date: str
89
+ The ISO 8601 date (YYYY-MM-DD) the document was initially issued.
90
+
91
+ add_tags: List[str|int]
92
+ Assign tags. Requires the ID or the exact name of the tags.
93
+ remove_tags: List[str|int]
94
+ Unassign tags. Requires the ID or the exact name of the tags.
95
+
96
+ add_custom_fields: List[CustomFieldKeyValue]
97
+ Assign custom fields (--custom-fields <NAME|ID>), optionally set a value (--custom-fields <NAME|ID>=VALUE).
98
+ To clear a custom field, set VALUE to an empty string.
99
+ remove_custom_fields: List[CustomFieldKeyValue]
100
+ Unassign given custom fields.
101
+ """
102
+
103
+ async with PaperlessAsyncAPI() as paperless:
104
+ # Define how document should be updated
105
+ only_changed: bool = True
106
+ document = await paperless.documents(id)
107
+
108
+ if asn:
109
+ document.archive_serial_number = asn
110
+
111
+ if correspondent:
112
+ document.correspondent = correspondent
113
+
114
+ if document_type:
115
+ document.document_type = document_type
116
+
117
+ if storage_path:
118
+ document.storage_path = storage_path
119
+
120
+ if title:
121
+ document.title = title
122
+
123
+ if created_date:
124
+ from datetime import datetime
125
+ document.created = datetime.strptime(created_date, "%Y-%m-%d").date()
126
+
127
+ if remove_tags:
128
+ # Only keep tags not in `remove_tags``
129
+ document.tags = [t for t in document.tags if t not in remove_tags]
130
+
131
+ if add_tags:
132
+ # Union existing and new tags, removing duplicate entries
133
+ document.tags = list(dict.fromkeys(document.tags + add_tags))
134
+
135
+ if remove_custom_fields:
136
+ # Remove given custom field if it's assigned to document
137
+ for f in remove_custom_fields:
138
+ document.custom_fields.remove(f["id"])
139
+
140
+ # Custom fields are only updated by paperless-api when updating all fields (PUT)
141
+ only_changed = False
142
+
143
+ if add_custom_fields:
144
+ # Update existing custom fields with possibly new values, or add new ones
145
+ for f in add_custom_fields:
146
+ existing_custom_field = document.custom_fields.default(f["id"])
147
+ if existing_custom_field is not None:
148
+ if f["value"] is not None:
149
+ existing_custom_field.value = f["value"]
150
+ else:
151
+ document.custom_fields.add(CustomFieldValue(field=f["id"], value=f["value"]))
152
+
153
+ # Custom fields are only updated by paperless-api when updating all fields (PUT)
154
+ only_changed = False
155
+
156
+ try:
157
+ await document.update(only_changed=only_changed)
158
+ except Exception as e:
159
+ raise ValueError(str(e))
@@ -0,0 +1,120 @@
1
+ """Method for retrieving information about a document."""
2
+
3
+ from typing import Annotated, Optional
4
+
5
+ from cyclopts import Parameter
6
+
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from pypaperless_cli.api import PaperlessAsyncAPI
11
+ from pypaperless_cli.const import GUI_PATH
12
+ from pypaperless_cli.config import config as appconfig
13
+ from pypaperless_cli.utils.highlighter import highlight_none
14
+ from pypaperless_cli.utils.types import Document
15
+
16
+ async def show(
17
+ id: Document, /, *,
18
+ json: Annotated[Optional[bool], Parameter(
19
+ negative = [],
20
+ show_default = False
21
+ )] = False,
22
+ ) -> None:
23
+
24
+ """Show information about a document.
25
+
26
+ Parameters
27
+ ----------
28
+ id: int
29
+ The ID of the document to show information about.
30
+ json: bool
31
+ If given, the information is printed as JSON.
32
+ """
33
+
34
+ async with PaperlessAsyncAPI() as paperless:
35
+ document = await paperless.documents(id)
36
+
37
+ # Everything except created date is optional
38
+ # therefore initialize possibly empty fields
39
+ doc_title = None
40
+ doc_type = None
41
+ correspondent = None
42
+ storage_path = None
43
+ tags = []
44
+ custom_fields = []
45
+
46
+ if len(document.title) > 0:
47
+ doc_title = document.title
48
+
49
+ if document.document_type is not None:
50
+ _doc_type = await paperless.document_types(document.document_type)
51
+ doc_type = _doc_type.name
52
+
53
+ if document.correspondent is not None:
54
+ _correspondent = await paperless.correspondents(document.correspondent)
55
+ correspondent = _correspondent.name
56
+
57
+ if document.storage_path is not None:
58
+ _storage_path = await paperless.storage_paths(document.storage_path)
59
+ storage_path = f"{_storage_path.name}\n({_storage_path.path})"
60
+
61
+
62
+ if document.tags:
63
+ filters = {
64
+ "id__in": ",".join(map(str, document.tags))
65
+ }
66
+ async with paperless.tags.reduce(**filters) as filtered:
67
+ async for tag in filtered:
68
+ tags.append(tag)
69
+
70
+ custom_field_values = list(document.custom_fields)
71
+ if custom_field_values:
72
+ filters = {
73
+ "id__in": ",".join(str(f.field) for f in custom_field_values)
74
+ }
75
+ async with paperless.custom_fields.reduce(**filters) as filtered:
76
+ async for field in filtered:
77
+ custom_fields.append({
78
+ "id": field.id,
79
+ "name": field.name,
80
+ "value": next(f.value for f in custom_field_values if f.field == field.id),
81
+ "data_type": field.data_type
82
+ })
83
+
84
+ if json:
85
+ Console().print_json(data=document._data)
86
+
87
+ else:
88
+ table = Table.grid(padding=(0,3))
89
+
90
+ table.add_column(style="blue")
91
+ table.add_column(style="green", no_wrap=True)
92
+
93
+ # Explicitly check title as NoneHighlighter doesn't work well with additional styles
94
+ if doc_title is not None:
95
+ table.add_row("[b]Title", f"[b]{doc_title}")
96
+ else:
97
+ table.add_row("[b]Title", f"[b purple]{str(doc_title)}")
98
+
99
+ table.add_row("ID", str(document.id))
100
+ table.add_row("ASN", highlight_none(str(document.archive_serial_number)))
101
+ table.add_row("Created", str(document.created_date))
102
+ table.add_row("Correspondent", highlight_none(str(correspondent)))
103
+ table.add_row("Document type", highlight_none(str(doc_type)))
104
+ table.add_row("Storage path", highlight_none(str(storage_path)))
105
+
106
+ if tags:
107
+ table.add_row("Tags", "\n".join([tag.name for tag in tags]))
108
+ else:
109
+ table.add_row("Tags", highlight_none(str(None)))
110
+
111
+ table.add_row("Details", f"{appconfig.current.host}{GUI_PATH['documents_details'].format(pk=document.id)}")
112
+
113
+ table.add_row("[white]Custom fields")
114
+ if custom_fields:
115
+ for custom_field in custom_fields:
116
+ table.add_row(custom_field["name"], highlight_none(str(custom_field["value"])))
117
+ else:
118
+ table.add_row(highlight_none(str(None)))
119
+
120
+ Console().print(table)
@@ -0,0 +1,2 @@
1
+ from pypaperless_cli.config.config import config
2
+ from pypaperless_cli.config.account import Account