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.
@@ -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
@@ -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