labapi 1.0.3__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.
- labapi/__init__.py +77 -0
- labapi/client.py +835 -0
- labapi/entry/__init__.py +28 -0
- labapi/entry/attachment.py +191 -0
- labapi/entry/collection.py +209 -0
- labapi/entry/comment.py +15 -0
- labapi/entry/entries/__init__.py +22 -0
- labapi/entry/entries/attachment.py +148 -0
- labapi/entry/entries/base.py +139 -0
- labapi/entry/entries/text.py +69 -0
- labapi/entry/entries/unknown.py +41 -0
- labapi/entry/entries/widget.py +29 -0
- labapi/exceptions.py +80 -0
- labapi/py.typed +1 -0
- labapi/tree/__init__.py +21 -0
- labapi/tree/collection.py +163 -0
- labapi/tree/directory.py +57 -0
- labapi/tree/mixins.py +852 -0
- labapi/tree/notebook.py +69 -0
- labapi/tree/page.py +218 -0
- labapi/user.py +146 -0
- labapi/util/__init__.py +45 -0
- labapi/util/browser.py +125 -0
- labapi/util/extract.py +124 -0
- labapi/util/path.py +367 -0
- labapi/util/types.py +76 -0
- labapi-1.0.3.dist-info/METADATA +210 -0
- labapi-1.0.3.dist-info/RECORD +31 -0
- labapi-1.0.3.dist-info/WHEEL +5 -0
- labapi-1.0.3.dist-info/licenses/LICENSE +121 -0
- labapi-1.0.3.dist-info/top_level.txt +1 -0
labapi/entry/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""LabArchives Entry Package.
|
|
2
|
+
|
|
3
|
+
This package defines the core components for handling various types of entries
|
|
4
|
+
within LabArchives pages, including base entry classes, specific entry types
|
|
5
|
+
(text, widget, attachment), and collections of entries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .attachment import Attachment
|
|
9
|
+
from .collection import Entries
|
|
10
|
+
from .comment import Comment
|
|
11
|
+
from .entries.attachment import AttachmentEntry
|
|
12
|
+
from .entries.base import Entry
|
|
13
|
+
from .entries.text import HeaderEntry, PlainTextEntry, TextEntry
|
|
14
|
+
from .entries.unknown import UnknownEntry
|
|
15
|
+
from .entries.widget import WidgetEntry
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Attachment",
|
|
19
|
+
"AttachmentEntry",
|
|
20
|
+
"Comment",
|
|
21
|
+
"Entries",
|
|
22
|
+
"Entry",
|
|
23
|
+
"HeaderEntry",
|
|
24
|
+
"PlainTextEntry",
|
|
25
|
+
"TextEntry",
|
|
26
|
+
"UnknownEntry",
|
|
27
|
+
"WidgetEntry",
|
|
28
|
+
]
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Attachment data structure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections.abc import Buffer
|
|
8
|
+
from contextlib import ExitStack
|
|
9
|
+
from mimetypes import guess_type
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import IO, Any, BinaryIO, Protocol, cast
|
|
12
|
+
|
|
13
|
+
# NOTE: from Pylance
|
|
14
|
+
# Unfortunately PEP 688 does not allow us to distinguish read-only
|
|
15
|
+
# from writable buffers. We use these aliases for readability for now.
|
|
16
|
+
# Perhaps a future extension of the buffer protocol will allow us to
|
|
17
|
+
# distinguish these cases in the type system.
|
|
18
|
+
# Same as WriteableBuffer, but also includes read-only buffer types (like bytes).
|
|
19
|
+
type ReadableBuffer = Buffer # stable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NamedBinaryIO(Protocol):
|
|
23
|
+
"""Binary file-like object with a ``name`` attribute."""
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def name(self) -> str:
|
|
27
|
+
"""Return the local filename for this stream."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
def read(self, size: int = -1, /) -> bytes:
|
|
31
|
+
"""Read bytes from the stream."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
def write(self, data: ReadableBuffer, /) -> int:
|
|
35
|
+
"""Write bytes to the stream."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def seek(self, offset: int, whence: int = 0, /) -> int:
|
|
39
|
+
"""Move the stream cursor."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
def tell(self) -> int:
|
|
43
|
+
"""Return the current stream cursor position."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
def close(self) -> None:
|
|
47
|
+
"""Close the stream."""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def seekable(self) -> bool:
|
|
51
|
+
"""Return whether the stream supports random access."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Attachment:
|
|
56
|
+
"""Represents an attachment file with associated metadata.
|
|
57
|
+
|
|
58
|
+
This class wraps a file-like object (such as BytesIO or TemporaryFile) along
|
|
59
|
+
with metadata like MIME type, filename, and caption. It provides a convenient
|
|
60
|
+
interface for working with file attachments in LabArchives.
|
|
61
|
+
|
|
62
|
+
.. note::
|
|
63
|
+
Write operations to the backing buffer need explicit syncing with the server.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def from_file(file: NamedBinaryIO) -> Attachment:
|
|
68
|
+
"""Create an attachment by cloning a seekable file object.
|
|
69
|
+
|
|
70
|
+
The content of the provided file is copied into a temporary buffer,
|
|
71
|
+
making the Attachment independent of the original file's state.
|
|
72
|
+
The MIME type is automatically guessed from the local file name.
|
|
73
|
+
If the MIME type cannot be determined, it defaults to
|
|
74
|
+
"application/octet-stream".
|
|
75
|
+
|
|
76
|
+
:param file: The file object to create an attachment from. Must have a `name` attribute.
|
|
77
|
+
:returns: A new Attachment object wrapping a clone of the file.
|
|
78
|
+
"""
|
|
79
|
+
if not file.seekable():
|
|
80
|
+
raise ValueError("Attachment.from_file requires a seekable file object")
|
|
81
|
+
|
|
82
|
+
remote_filename = Path(file.name).name
|
|
83
|
+
mime_type = guess_type(file.name)[0] or "application/octet-stream"
|
|
84
|
+
original_position = file.tell()
|
|
85
|
+
|
|
86
|
+
# Create a spooled temporary file as the new backing buffer.
|
|
87
|
+
# It stays in memory until it reaches 4MB, then rolls over to disk.
|
|
88
|
+
with ExitStack() as stack:
|
|
89
|
+
backing = cast(
|
|
90
|
+
BinaryIO,
|
|
91
|
+
stack.enter_context(
|
|
92
|
+
tempfile.SpooledTemporaryFile(max_size=4 * 1024 * 1024, mode="w+b")
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
file.seek(0)
|
|
97
|
+
shutil.copyfileobj(file, backing)
|
|
98
|
+
finally:
|
|
99
|
+
file.seek(original_position)
|
|
100
|
+
backing.seek(0)
|
|
101
|
+
stack.pop_all()
|
|
102
|
+
|
|
103
|
+
return Attachment(
|
|
104
|
+
backing,
|
|
105
|
+
mime_type,
|
|
106
|
+
remote_filename,
|
|
107
|
+
caption=f"API-uploaded {mime_type} file.",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
backing: IO[bytes],
|
|
113
|
+
mime_type: str,
|
|
114
|
+
filename: str,
|
|
115
|
+
caption: str,
|
|
116
|
+
):
|
|
117
|
+
"""Initialize an attachment wrapper.
|
|
118
|
+
|
|
119
|
+
:param backing: The file-like object that contains the attachment data.
|
|
120
|
+
Can be a BufferedRandom, BufferedReader, BytesIO, or TemporaryFile.
|
|
121
|
+
:param mime_type: The MIME type of the attachment (e.g., "image/png", "application/pdf").
|
|
122
|
+
:param filename: The filename of the attachment.
|
|
123
|
+
:param caption: A descriptive caption for the attachment.
|
|
124
|
+
"""
|
|
125
|
+
self._backing = backing
|
|
126
|
+
if self._backing.seekable():
|
|
127
|
+
self._backing.seek(0)
|
|
128
|
+
|
|
129
|
+
self._mime_type = mime_type
|
|
130
|
+
self._filename = filename
|
|
131
|
+
self._caption = caption
|
|
132
|
+
|
|
133
|
+
def __getattr__(self, attr: str) -> Any:
|
|
134
|
+
"""Delegate unknown attributes to the backing file object.
|
|
135
|
+
|
|
136
|
+
This allows the Attachment to behave like the underlying file object
|
|
137
|
+
for operations like read(), write(), etc.
|
|
138
|
+
|
|
139
|
+
:param attr: The attribute name to access on the backing object.
|
|
140
|
+
:returns: The attribute value from the backing object.
|
|
141
|
+
:raises AttributeError: If the attribute does not exist on the backing object.
|
|
142
|
+
"""
|
|
143
|
+
return getattr(self._backing, attr)
|
|
144
|
+
|
|
145
|
+
def read(self, size: int = -1, /) -> bytes:
|
|
146
|
+
"""Read bytes from the backing attachment stream."""
|
|
147
|
+
return self._backing.read(size)
|
|
148
|
+
|
|
149
|
+
def write(self, data: ReadableBuffer, /) -> int:
|
|
150
|
+
"""Write bytes to the backing attachment stream."""
|
|
151
|
+
return self._backing.write(data)
|
|
152
|
+
|
|
153
|
+
def seek(self, offset: int, whence: int = 0, /) -> int:
|
|
154
|
+
"""Move the backing stream cursor."""
|
|
155
|
+
return self._backing.seek(offset, whence)
|
|
156
|
+
|
|
157
|
+
def tell(self) -> int:
|
|
158
|
+
"""Return the current backing stream cursor position."""
|
|
159
|
+
return self._backing.tell()
|
|
160
|
+
|
|
161
|
+
def close(self) -> None:
|
|
162
|
+
"""Close the backing attachment stream."""
|
|
163
|
+
self._backing.close()
|
|
164
|
+
|
|
165
|
+
def seekable(self) -> bool:
|
|
166
|
+
"""Return whether the backing stream supports random access."""
|
|
167
|
+
return self._backing.seekable()
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def filename(self) -> str:
|
|
171
|
+
"""Return the attachment filename.
|
|
172
|
+
|
|
173
|
+
:returns: The filename.
|
|
174
|
+
"""
|
|
175
|
+
return self._filename
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def mime_type(self) -> str:
|
|
179
|
+
"""Return the attachment MIME type.
|
|
180
|
+
|
|
181
|
+
:returns: The MIME type (e.g., "image/png", "application/pdf").
|
|
182
|
+
"""
|
|
183
|
+
return self._mime_type
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def caption(self) -> str:
|
|
187
|
+
"""Return the attachment caption.
|
|
188
|
+
|
|
189
|
+
:returns: The caption text.
|
|
190
|
+
"""
|
|
191
|
+
return self._caption
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Entries collection class."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Sequence
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from html import escape
|
|
8
|
+
from io import BytesIO
|
|
9
|
+
from json import dumps
|
|
10
|
+
from typing import TYPE_CHECKING, Any, SupportsIndex, TypeVar, overload, override
|
|
11
|
+
|
|
12
|
+
from labapi.util import extract_etree
|
|
13
|
+
|
|
14
|
+
from .attachment import Attachment
|
|
15
|
+
from .entries import AttachmentEntry, Entry, TextEntry
|
|
16
|
+
|
|
17
|
+
E = TypeVar("E", bound="Entry[Any]")
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from labapi.tree import NotebookPage
|
|
21
|
+
from labapi.user import User
|
|
22
|
+
from labapi.util import JsonData
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Entries(Sequence["Entry[Any]"]):
|
|
26
|
+
"""A collection of entries on a LabArchives page.
|
|
27
|
+
|
|
28
|
+
This class provides a sequence-like interface for managing entries within
|
|
29
|
+
a page, including a generic method for creating new entries by class.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, entries: Sequence[Entry[Any]], user: User, page: NotebookPage):
|
|
33
|
+
"""Initialize an entries collection.
|
|
34
|
+
|
|
35
|
+
:param entries: A sequence of :class:`~labapi.entry.Entry` objects.
|
|
36
|
+
:param user: The authenticated user.
|
|
37
|
+
:param page: The page that this collection belongs to.
|
|
38
|
+
"""
|
|
39
|
+
super().__init__()
|
|
40
|
+
self._user = user
|
|
41
|
+
self._page = page
|
|
42
|
+
self._entries: list[Entry[Any]] = list(entries)
|
|
43
|
+
|
|
44
|
+
@overload
|
|
45
|
+
def __getitem__(self, index: SupportsIndex) -> Entry[Any]:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
@overload
|
|
49
|
+
def __getitem__(self, index: str) -> Entry[Any]:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
@overload
|
|
53
|
+
def __getitem__(self, index: slice) -> Sequence[Entry[Any]]:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
@override
|
|
57
|
+
def __getitem__(
|
|
58
|
+
self, index: SupportsIndex | str | slice[Any, Any, Any]
|
|
59
|
+
) -> Entry[Any] | Sequence[Entry[Any]]:
|
|
60
|
+
"""Look up entries by index, slice, or entry identifier."""
|
|
61
|
+
if isinstance(index, str):
|
|
62
|
+
for entry in self._entries:
|
|
63
|
+
if entry.id == index:
|
|
64
|
+
return entry
|
|
65
|
+
raise KeyError(f"Entry with id '{index}' not found")
|
|
66
|
+
return self._entries[index]
|
|
67
|
+
|
|
68
|
+
@override
|
|
69
|
+
def __iter__(self) -> Iterator[Entry[Any]]:
|
|
70
|
+
"""Iterate over a snapshot of this page's entries."""
|
|
71
|
+
return iter(tuple(self._entries))
|
|
72
|
+
|
|
73
|
+
@override
|
|
74
|
+
def __reversed__(self) -> Iterator[Entry[Any]]:
|
|
75
|
+
"""Iterate over a snapshot of this page's entries in reverse order."""
|
|
76
|
+
return reversed(tuple(self._entries))
|
|
77
|
+
|
|
78
|
+
@override
|
|
79
|
+
def __len__(self):
|
|
80
|
+
"""Return the number of entries in this collection."""
|
|
81
|
+
return len(self._entries)
|
|
82
|
+
|
|
83
|
+
# TODO delete entries
|
|
84
|
+
|
|
85
|
+
def create_json_entry(
|
|
86
|
+
self,
|
|
87
|
+
data: JsonData,
|
|
88
|
+
*,
|
|
89
|
+
filename: str | None = None,
|
|
90
|
+
caption: str | None = None,
|
|
91
|
+
) -> tuple[AttachmentEntry, TextEntry]:
|
|
92
|
+
"""Create a JSON attachment plus a companion reference text entry.
|
|
93
|
+
|
|
94
|
+
This method uploads JSON data as an attachment file and creates a
|
|
95
|
+
companion text entry that references the attachment and displays
|
|
96
|
+
a formatted preview of the JSON data.
|
|
97
|
+
|
|
98
|
+
:param data: The JSON-serializable data to upload.
|
|
99
|
+
:param filename: Optional stable filename for the uploaded JSON attachment.
|
|
100
|
+
:param caption: Optional label/caption for the generated attachment and reference entry.
|
|
101
|
+
:returns: A tuple containing the attachment entry and the text entry.
|
|
102
|
+
"""
|
|
103
|
+
# TODO treat this as one entry in the code
|
|
104
|
+
|
|
105
|
+
name = filename or f"uploaded_data_{datetime.now().timestamp():.0f}.json"
|
|
106
|
+
display_caption = caption or name
|
|
107
|
+
preview_json = escape(dumps(data, indent=4))
|
|
108
|
+
|
|
109
|
+
file_entry = self.create(
|
|
110
|
+
AttachmentEntry,
|
|
111
|
+
Attachment(
|
|
112
|
+
BytesIO(dumps(data).encode()),
|
|
113
|
+
"application/json",
|
|
114
|
+
name,
|
|
115
|
+
display_caption,
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
text_entry = self.create(
|
|
120
|
+
TextEntry,
|
|
121
|
+
f"""
|
|
122
|
+
<p>Reference Attachment: {escape(display_caption)}</p>
|
|
123
|
+
<p>Entry ID: {escape(file_entry.id)}</p>
|
|
124
|
+
<pre>
|
|
125
|
+
{preview_json}
|
|
126
|
+
</pre>
|
|
127
|
+
""",
|
|
128
|
+
)
|
|
129
|
+
return file_entry, text_entry
|
|
130
|
+
|
|
131
|
+
@overload
|
|
132
|
+
def create(
|
|
133
|
+
self,
|
|
134
|
+
cls: type[AttachmentEntry],
|
|
135
|
+
data: Attachment,
|
|
136
|
+
*,
|
|
137
|
+
client_ip: str | None = None,
|
|
138
|
+
) -> AttachmentEntry: ...
|
|
139
|
+
|
|
140
|
+
@overload
|
|
141
|
+
def create(self, cls: type[E], data: str, *, client_ip: str | None = None) -> E: ...
|
|
142
|
+
|
|
143
|
+
def create(
|
|
144
|
+
self, cls: type[E], data: str | Attachment, *, client_ip: str | None = None
|
|
145
|
+
) -> E:
|
|
146
|
+
"""Create a new entry on the page.
|
|
147
|
+
|
|
148
|
+
This method supports creating any entry type by passing the entry class directly,
|
|
149
|
+
similar to :meth:`~labapi.tree.mixins.AbstractTreeContainer.create`. The created
|
|
150
|
+
entry is automatically added to the collection.
|
|
151
|
+
|
|
152
|
+
:param cls: The entry class to create (e.g., :class:`~labapi.entry.entries.TextEntry`,
|
|
153
|
+
:class:`~labapi.entry.entries.HeaderEntry`,
|
|
154
|
+
:class:`~labapi.entry.entries.AttachmentEntry`).
|
|
155
|
+
:param data: The content of the entry. For text-based entries, this should be a string.
|
|
156
|
+
For :class:`~labapi.entry.entries.AttachmentEntry`, this should be an
|
|
157
|
+
:class:`~labapi.entry.Attachment` object.
|
|
158
|
+
:param client_ip: Optional end-user IP to pass through on attachment uploads.
|
|
159
|
+
:returns: The newly created entry object of the specified type.
|
|
160
|
+
:raises RuntimeError: If the API call to create the entry fails.
|
|
161
|
+
"""
|
|
162
|
+
if issubclass(cls, AttachmentEntry):
|
|
163
|
+
if not isinstance(data, Attachment):
|
|
164
|
+
raise TypeError(
|
|
165
|
+
f"{cls.__name__} requires Attachment data, got "
|
|
166
|
+
f"{type(data).__name__}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if data._backing.seekable(): # pyright: ignore[reportPrivateUsage]
|
|
170
|
+
data._backing.seek(0) # pyright: ignore[reportPrivateUsage]
|
|
171
|
+
|
|
172
|
+
upload_kwargs = {
|
|
173
|
+
"filename": data.filename,
|
|
174
|
+
"caption": data.caption,
|
|
175
|
+
"nbid": self._page.root.id,
|
|
176
|
+
"pid": self._page.id,
|
|
177
|
+
"change_description": "File uploaded via API",
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if client_ip is not None:
|
|
181
|
+
upload_kwargs["client_ip"] = client_ip
|
|
182
|
+
|
|
183
|
+
entry_tree = self._user.api_post(
|
|
184
|
+
"entries/add_attachment",
|
|
185
|
+
data._backing, # pyright: ignore[reportPrivateUsage, reportArgumentType]
|
|
186
|
+
**upload_kwargs,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
eid = extract_etree(entry_tree, {"entry": {"eid": str}})["eid"]
|
|
190
|
+
entry = cls(eid, data.caption, self._user)
|
|
191
|
+
|
|
192
|
+
else:
|
|
193
|
+
if not isinstance(data, str):
|
|
194
|
+
raise TypeError(
|
|
195
|
+
f"{cls.__name__} requires str data, got {type(data).__name__}"
|
|
196
|
+
)
|
|
197
|
+
entry_tree = self._user.api_post(
|
|
198
|
+
"entries/add_entry",
|
|
199
|
+
{"entry_data": data},
|
|
200
|
+
part_type=cls._part_type, # pyright: ignore[reportPrivateUsage]
|
|
201
|
+
pid=self._page.id,
|
|
202
|
+
nbid=self._page.root.id,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
eid = extract_etree(entry_tree, {"entry": {"eid": str}})["eid"]
|
|
206
|
+
entry = cls(eid, data, self._user)
|
|
207
|
+
|
|
208
|
+
self._entries.append(entry)
|
|
209
|
+
return entry
|
labapi/entry/comment.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Comment Module.
|
|
2
|
+
|
|
3
|
+
This module defines the :class:`~labapi.entry.comment.Comment` class,
|
|
4
|
+
representing a comment associated with an entity (e.g., an entry) within LabArchives.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Comment:
|
|
11
|
+
"""Represents a comment associated with an entity in LabArchives.
|
|
12
|
+
|
|
13
|
+
Currently, this class is a placeholder and does not contain specific
|
|
14
|
+
attributes or methods for comment management.
|
|
15
|
+
"""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""LabArchives Entry Types Package.
|
|
2
|
+
|
|
3
|
+
This package defines various types of entries that can exist on a LabArchives page,
|
|
4
|
+
including a base entry class and specific implementations for text, widget,
|
|
5
|
+
and attachment entries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .attachment import AttachmentEntry
|
|
9
|
+
from .base import Entry
|
|
10
|
+
from .text import HeaderEntry, PlainTextEntry, TextEntry
|
|
11
|
+
from .unknown import UnknownEntry
|
|
12
|
+
from .widget import WidgetEntry
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AttachmentEntry",
|
|
16
|
+
"Entry",
|
|
17
|
+
"HeaderEntry",
|
|
18
|
+
"PlainTextEntry",
|
|
19
|
+
"TextEntry",
|
|
20
|
+
"UnknownEntry",
|
|
21
|
+
"WidgetEntry",
|
|
22
|
+
]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Attachment Entry Module.
|
|
2
|
+
|
|
3
|
+
This module defines the :class:`~labapi.entry.entries.attachment.AttachmentEntry` class,
|
|
4
|
+
which represents an attachment entry within a LabArchives page.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from email.message import Message
|
|
10
|
+
from io import BytesIO
|
|
11
|
+
from tempfile import TemporaryFile
|
|
12
|
+
from typing import IO, TYPE_CHECKING, override
|
|
13
|
+
|
|
14
|
+
from labapi.entry.attachment import Attachment
|
|
15
|
+
from labapi.exceptions import ApiError
|
|
16
|
+
|
|
17
|
+
from .base import Entry
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from labapi.user import User
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _make_backing_io(use_tempfile: bool) -> IO[bytes]:
|
|
24
|
+
return TemporaryFile() if use_tempfile else BytesIO()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AttachmentEntry(Entry[Attachment], part_type="Attachment"):
|
|
28
|
+
"""Represents an attachment entry on a LabArchives page.
|
|
29
|
+
|
|
30
|
+
This class handles the retrieval and updating of file attachments,
|
|
31
|
+
providing access to the attachment's content, filename, and caption.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, eid: str, caption: str, user: User):
|
|
35
|
+
"""Initialize an attachment entry.
|
|
36
|
+
|
|
37
|
+
:param eid: The unique ID of the entry.
|
|
38
|
+
:param caption: The caption associated with the attachment.
|
|
39
|
+
:param user: The authenticated user.
|
|
40
|
+
"""
|
|
41
|
+
super().__init__(eid, caption, user)
|
|
42
|
+
self._filedata: Attachment | None = None
|
|
43
|
+
self._filename: str | None = None
|
|
44
|
+
self._mime_type: str | None = None
|
|
45
|
+
|
|
46
|
+
def _ensure_attachment(self, use_tempfile: bool) -> None:
|
|
47
|
+
if self._filedata is None or self._filedata.closed:
|
|
48
|
+
output = _make_backing_io(use_tempfile)
|
|
49
|
+
|
|
50
|
+
with self._user.client.stream_api_get(
|
|
51
|
+
"entries/entry_attachment", uid=self._user.id, eid=self.id
|
|
52
|
+
) as attachment_stream:
|
|
53
|
+
for chunk in attachment_stream:
|
|
54
|
+
output.write(chunk)
|
|
55
|
+
|
|
56
|
+
headers = attachment_stream.headers
|
|
57
|
+
|
|
58
|
+
msg = Message()
|
|
59
|
+
msg["Content-Type"] = (
|
|
60
|
+
headers.get("Content-Type") or "application/octet-stream"
|
|
61
|
+
)
|
|
62
|
+
msg["Content-Disposition"] = headers.get("Content-Disposition")
|
|
63
|
+
filename = msg.get_filename()
|
|
64
|
+
mime_type = msg.get_content_type()
|
|
65
|
+
|
|
66
|
+
if filename is None:
|
|
67
|
+
raise ApiError("Could not determine filename from API response headers")
|
|
68
|
+
|
|
69
|
+
self._filedata = Attachment(output, mime_type, filename, self._data)
|
|
70
|
+
|
|
71
|
+
def get_attachment(self, use_tempfile: bool = False) -> Attachment:
|
|
72
|
+
"""Return the attachment payload as an independent stream copy.
|
|
73
|
+
|
|
74
|
+
The attachment data is fetched from the LabArchives API and cached.
|
|
75
|
+
Subsequent calls will return the cached data.
|
|
76
|
+
|
|
77
|
+
:param use_tempfile: If True, the attachment data will be stored in a
|
|
78
|
+
temporary file; otherwise, in an in-memory BytesIO object.
|
|
79
|
+
Defaults to False.
|
|
80
|
+
:returns: An :class:`~labapi.entry.attachment.Attachment` object containing the file data and metadata.
|
|
81
|
+
"""
|
|
82
|
+
self._ensure_attachment(use_tempfile)
|
|
83
|
+
|
|
84
|
+
assert self._filedata is not None
|
|
85
|
+
output = _make_backing_io(use_tempfile)
|
|
86
|
+
|
|
87
|
+
# Return an independent copy so each caller gets isolated read/seek/close state
|
|
88
|
+
# while still sharing a single downloaded backing attachment in the cache.
|
|
89
|
+
self._filedata.seek(0)
|
|
90
|
+
output.write(self._filedata.read())
|
|
91
|
+
output.seek(0)
|
|
92
|
+
|
|
93
|
+
return Attachment(
|
|
94
|
+
output,
|
|
95
|
+
self._filedata.mime_type,
|
|
96
|
+
self._filedata.filename,
|
|
97
|
+
self._filedata.caption,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
@override
|
|
102
|
+
def content(self) -> Attachment:
|
|
103
|
+
"""Return the attachment content.
|
|
104
|
+
|
|
105
|
+
This property retrieves the attachment data, caching it for subsequent access.
|
|
106
|
+
|
|
107
|
+
:returns: The attachment object.
|
|
108
|
+
"""
|
|
109
|
+
return self.get_attachment()
|
|
110
|
+
|
|
111
|
+
@content.setter
|
|
112
|
+
@override
|
|
113
|
+
def content(self, value: Attachment):
|
|
114
|
+
"""Set the attachment content.
|
|
115
|
+
|
|
116
|
+
This operation updates the attachment in LabArchives via an API call
|
|
117
|
+
and invalidates any previously cached attachment data.
|
|
118
|
+
|
|
119
|
+
:param value: The new attachment object to upload.
|
|
120
|
+
"""
|
|
121
|
+
# NOTE: this implicitly invalidates all previous Attachments
|
|
122
|
+
# NOTE: if every time content is called we give a new copy anyways that's fine
|
|
123
|
+
# (see get_attachment())
|
|
124
|
+
if value._backing.seekable(): # pyright: ignore[reportPrivateUsage]
|
|
125
|
+
value._backing.seek(0) # pyright: ignore[reportPrivateUsage]
|
|
126
|
+
|
|
127
|
+
self._user.api_post(
|
|
128
|
+
"entries/update_attachment",
|
|
129
|
+
value._backing, # pyright: ignore[reportPrivateUsage, reportArgumentType]
|
|
130
|
+
filename=value.filename,
|
|
131
|
+
caption=value.caption,
|
|
132
|
+
eid=self.id,
|
|
133
|
+
change_description="File updated via API",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
self._data = value.caption
|
|
137
|
+
|
|
138
|
+
if self._filedata:
|
|
139
|
+
self._filedata.close()
|
|
140
|
+
self._filedata = None
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def caption(self) -> str:
|
|
144
|
+
"""Return the attachment caption.
|
|
145
|
+
|
|
146
|
+
:returns: The caption string.
|
|
147
|
+
"""
|
|
148
|
+
return self._data
|