Documentation of python magic class methods
This commit is contained in:
parent
864ca91e5c
commit
e33fecaef0
13 changed files with 119 additions and 32 deletions
|
|
@ -129,8 +129,6 @@ ignore = [
|
||||||
"D213", # incompatible with D212
|
"D213", # incompatible with D212
|
||||||
"D400", # A stricter version of #415 that doesn't allow !
|
"D400", # A stricter version of #415 that doesn't allow !
|
||||||
# The checkers below should be turned on as they complain about missing docstrings.
|
# The checkers below should be turned on as they complain about missing docstrings.
|
||||||
"D105",
|
|
||||||
"D107",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint.pydocstyle]
|
[tool.ruff.lint.pydocstyle]
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,12 @@ class Zenodo:
|
||||||
"""A class that packages data for Zenodo."""
|
"""A class that packages data for Zenodo."""
|
||||||
|
|
||||||
def __init__(self, api_token, use_sandbox=True):
|
def __init__(self, api_token, use_sandbox=True):
|
||||||
|
"""Initialise the zenodo packager with the API token.
|
||||||
|
|
||||||
|
:param api_token: Your Zenodo API key.
|
||||||
|
:param use_sandbox: True to pushing to ``sandbox.zenodo.org`` rather than
|
||||||
|
``zenodo.org`` for testing.
|
||||||
|
"""
|
||||||
self._api_token = api_token
|
self._api_token = api_token
|
||||||
self._use_sandbox = use_sandbox
|
self._use_sandbox = use_sandbox
|
||||||
if use_sandbox:
|
if use_sandbox:
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,13 @@ class OFMHandler(logging.Handler):
|
||||||
"""A logging.Handler that stores the most recent logs for access by the server."""
|
"""A logging.Handler that stores the most recent logs for access by the server."""
|
||||||
|
|
||||||
def __init__(self, level=logging.INFO, max_logs=250):
|
def __init__(self, level=logging.INFO, max_logs=250):
|
||||||
|
"""Inialise the handler with a set logging level and message buffer size.
|
||||||
|
|
||||||
|
:param level: The level of logs captured. As standard logs of INFO and above
|
||||||
|
are captured.
|
||||||
|
:param max_logs: The maximum number of logs to hold in memory. This sets
|
||||||
|
how many can be returned over HTTP.
|
||||||
|
"""
|
||||||
super().__init__(level=level)
|
super().__init__(level=level)
|
||||||
self._log = []
|
self._log = []
|
||||||
self._max_logs = max_logs
|
self._max_logs = max_logs
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@ class ScanDirectoryManager:
|
||||||
_base_scan_dir: str
|
_base_scan_dir: str
|
||||||
|
|
||||||
def __init__(self, base_scan_dir: str):
|
def __init__(self, base_scan_dir: str):
|
||||||
|
"""Initialise the scan directory manager.
|
||||||
|
|
||||||
|
:param base_scan_dir: Path of the directory that holds all scans.
|
||||||
|
"""
|
||||||
self._base_scan_dir = base_scan_dir
|
self._base_scan_dir = base_scan_dir
|
||||||
if not os.path.exists(self._base_scan_dir):
|
if not os.path.exists(self._base_scan_dir):
|
||||||
os.makedirs(self._base_scan_dir)
|
os.makedirs(self._base_scan_dir)
|
||||||
|
|
@ -194,17 +198,17 @@ class ScanDirectoryManager:
|
||||||
|
|
||||||
|
|
||||||
class ScanDirectory:
|
class ScanDirectory:
|
||||||
"""A class for handling interactions with scan directories.
|
"""A class for handling interactions with scan directories."""
|
||||||
|
|
||||||
Initalisation parameters:
|
|
||||||
name: the name of the scan (the scan directory basename)
|
|
||||||
base_scan_dir: Path of the directory that holds all scans
|
|
||||||
"""
|
|
||||||
|
|
||||||
_name: str
|
_name: str
|
||||||
_base_scan_dir: str
|
_base_scan_dir: str
|
||||||
|
|
||||||
def __init__(self, name: str, base_scan_dir: str):
|
def __init__(self, name: str, base_scan_dir: str):
|
||||||
|
"""Initialise the scan directory.
|
||||||
|
|
||||||
|
:param name: the name of the scan (the scan directory basename).
|
||||||
|
:param base_scan_dir: Path of the directory that holds all scans.
|
||||||
|
"""
|
||||||
self._name = name
|
self._name = name
|
||||||
self._base_scan_dir = base_scan_dir
|
self._base_scan_dir = base_scan_dir
|
||||||
if not os.path.isdir(self.dir_path):
|
if not os.path.isdir(self.dir_path):
|
||||||
|
|
|
||||||
|
|
@ -26,21 +26,7 @@ from .stage import StageDependency as Stage
|
||||||
|
|
||||||
|
|
||||||
class StackParams:
|
class StackParams:
|
||||||
"""A class for holding for scan parameters.
|
"""A class for holding for stack parameters, and returning computed ones."""
|
||||||
|
|
||||||
All arguments are keyword only
|
|
||||||
|
|
||||||
:param stack_dz: The number of motor steps between images
|
|
||||||
:param images_to_save: The number of images to save to disk
|
|
||||||
:param min_images_to_test: The minimum number of images in the stack before, the
|
|
||||||
stack is evaluated for focus. As more images are captured evaluation of the
|
|
||||||
focus is always evaluated with the same number of images. i.e. if
|
|
||||||
``min_images_to_test=9``, then 9 images are captured, if the stack is not well
|
|
||||||
focused, a 10th image is captured and images 2 to 10 are evaluated for focus
|
|
||||||
:param autofocus_dz: The number of steps in a full autofocus (when required)
|
|
||||||
:param images_dir: The directory to save images to disk
|
|
||||||
:param save_resolution: The resolution to save the captures to disk with
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -52,6 +38,20 @@ class StackParams:
|
||||||
images_dir: str,
|
images_dir: str,
|
||||||
save_resolution: tuple[int, int],
|
save_resolution: tuple[int, int],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Initalise the parameters. All arguments are keyword only.
|
||||||
|
|
||||||
|
:param stack_dz: The number of motor steps between images
|
||||||
|
:param images_to_save: The number of images to save to disk
|
||||||
|
:param min_images_to_test: The minimum number of images in the stack before,
|
||||||
|
the stack is evaluated for focus. As more images are captured evaluation
|
||||||
|
of the focus is always evaluated with the same number of images. i.e. if
|
||||||
|
``min_images_to_test=9``, then 9 images are captured, if the stack is not
|
||||||
|
well focused, a 10th image is captured and images 2 to 10 are evaluated
|
||||||
|
for focus
|
||||||
|
:param autofocus_dz: The number of steps in a full autofocus (when required)
|
||||||
|
:param images_dir: The directory to save images to disk
|
||||||
|
:param save_resolution: The resolution to save the captures to disk with
|
||||||
|
"""
|
||||||
if min_images_to_test < images_to_save:
|
if min_images_to_test < images_to_save:
|
||||||
raise ValueError("Can't save more images than the minimum number tested")
|
raise ValueError("Can't save more images than the minimum number tested")
|
||||||
if min_images_to_test % 2 == 0 or min_images_to_test <= 0:
|
if min_images_to_test % 2 == 0 or min_images_to_test <= 0:
|
||||||
|
|
@ -205,6 +205,13 @@ class JPEGSharpnessMonitor:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, stage: Stage, camera: Camera, portal: lt.deps.BlockingPortal):
|
def __init__(self, stage: Stage, camera: Camera, portal: lt.deps.BlockingPortal):
|
||||||
|
"""Initalise a new JPEGSharpnessMonitor. The args are injected automatically.
|
||||||
|
|
||||||
|
:param stage: A direct_thing_client dependency for the the microscope stage.
|
||||||
|
:param camera: A raw_thing_client depeendency for the camera. This is a raw
|
||||||
|
dependency as the underlying class needs to be
|
||||||
|
:param portal: The asyncio blocking portal for asynchronous task scheduling.
|
||||||
|
"""
|
||||||
self.camera = camera
|
self.camera = camera
|
||||||
self.stage = stage
|
self.stage = stage
|
||||||
self.portal = portal
|
self.portal = portal
|
||||||
|
|
|
||||||
|
|
@ -41,18 +41,19 @@ class CaptureError(RuntimeError):
|
||||||
|
|
||||||
|
|
||||||
class NoImageInMemoryError(RuntimeError):
|
class NoImageInMemoryError(RuntimeError):
|
||||||
"""An error called if no image in in memory when an method is called to use that image."""
|
"""An error called if no image is in memory when accessed."""
|
||||||
|
|
||||||
|
|
||||||
class CameraMemoryBuffer:
|
class CameraMemoryBuffer:
|
||||||
"""A class that holds images in memory. The images are by default PIL images.
|
"""A class that holds images in memory. The images are by default PIL images.
|
||||||
|
|
||||||
However subclasses of BaseCamera can use this class to store other object types
|
However subclasses of BaseCamera can use this class to store other object types.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_storage: dict[int, tuple[Any, Optional[dict]]]
|
_storage: dict[int, tuple[Any, Optional[dict]]]
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
"""Create the buffer instance."""
|
||||||
# This dictionary is the main store for data. Dictionaries are ordered since
|
# This dictionary is the main store for data. Dictionaries are ordered since
|
||||||
# Python 3.6, so the order in the dictionary is the capture order
|
# Python 3.6, so the order in the dictionary is the capture order
|
||||||
self._storage = {}
|
self._storage = {}
|
||||||
|
|
@ -142,16 +143,22 @@ class CameraMemoryBuffer:
|
||||||
|
|
||||||
|
|
||||||
class BaseCamera(lt.Thing):
|
class BaseCamera(lt.Thing):
|
||||||
"""The base class for all cameras. All cameras must directly inherit from this class."""
|
"""The base class for all cameras. All cameras must directly inherit from this class.
|
||||||
|
|
||||||
|
The connection to the camera hardware should be added to the ``__enter__`` method not
|
||||||
|
``__init__`` method of the subclass.
|
||||||
|
"""
|
||||||
|
|
||||||
mjpeg_stream = lt.outputs.MJPEGStreamDescriptor()
|
mjpeg_stream = lt.outputs.MJPEGStreamDescriptor()
|
||||||
lores_mjpeg_stream = lt.outputs.MJPEGStreamDescriptor()
|
lores_mjpeg_stream = lt.outputs.MJPEGStreamDescriptor()
|
||||||
_memory_buffer = CameraMemoryBuffer()
|
_memory_buffer = CameraMemoryBuffer()
|
||||||
|
|
||||||
def __enter__(self) -> None:
|
def __enter__(self) -> None:
|
||||||
|
"""Open hardware connection when the Thing context manager is opened."""
|
||||||
raise NotImplementedError("CameraThings must define their own __enter__ method")
|
raise NotImplementedError("CameraThings must define their own __enter__ method")
|
||||||
|
|
||||||
def __exit__(self, _exc_type, _exc_value, _traceback) -> None:
|
def __exit__(self, _exc_type, _exc_value, _traceback) -> None:
|
||||||
|
"""Close hardware connection when the Thing context manager is closed."""
|
||||||
raise NotImplementedError("CameraThings must define their own __exit__ method")
|
raise NotImplementedError("CameraThings must define their own __exit__ method")
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,19 @@ from . import BaseCamera, JPEGBlob
|
||||||
|
|
||||||
|
|
||||||
class OpenCVCamera(BaseCamera):
|
class OpenCVCamera(BaseCamera):
|
||||||
"""A Thing representing an OpenCV camera."""
|
"""A Thing that provides and interface to an OpenCV Camera."""
|
||||||
|
|
||||||
def __init__(self, camera_index: int = 0):
|
def __init__(self, camera_index: int = 0):
|
||||||
|
"""Iniatilise the thing storing the index of the camera to use.
|
||||||
|
|
||||||
|
:param camera_index: The index of the camera to use for the microscope.
|
||||||
|
"""
|
||||||
self.camera_index = camera_index
|
self.camera_index = camera_index
|
||||||
self._capture_thread: Optional[Thread] = None
|
self._capture_thread: Optional[Thread] = None
|
||||||
self._capture_enabled = False
|
self._capture_enabled = False
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
|
"""Start the capture thread when the Thing context manager is opened."""
|
||||||
self.cap = cv2.VideoCapture(self.camera_index)
|
self.cap = cv2.VideoCapture(self.camera_index)
|
||||||
self._capture_enabled = True
|
self._capture_enabled = True
|
||||||
self._capture_thread = Thread(target=self._capture_frames)
|
self._capture_thread = Thread(target=self._capture_frames)
|
||||||
|
|
@ -38,6 +43,10 @@ class OpenCVCamera(BaseCamera):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, _exc_type, _exc_value, _traceback):
|
def __exit__(self, _exc_type, _exc_value, _traceback):
|
||||||
|
"""Release the camera when the Thing context manager is closed.
|
||||||
|
|
||||||
|
Before releasing the camera the capture thread is closed.
|
||||||
|
"""
|
||||||
if self.stream_active:
|
if self.stream_active:
|
||||||
self._capture_enabled = False
|
self._capture_enabled = False
|
||||||
self._capture_thread.join()
|
self._capture_thread.join()
|
||||||
|
|
|
||||||
|
|
@ -104,9 +104,20 @@ class LensShading(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class StreamingPiCamera2(BaseCamera):
|
class StreamingPiCamera2(BaseCamera):
|
||||||
"""A Thing that represents an OpenCV camera."""
|
"""A Thing that provides and interface to the Raspberry Pi Camera.
|
||||||
|
|
||||||
|
Currently the Thing only supports the PiCamera v2 board. This needs
|
||||||
|
generalisation.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, camera_num: int = 0):
|
def __init__(self, camera_num: int = 0):
|
||||||
|
"""Initialise the camera with the given camera number.
|
||||||
|
|
||||||
|
This makes no connection to the camera (except to get the default tuning file).
|
||||||
|
|
||||||
|
:param camera_num: The number of the camera. This should generally be left as 0
|
||||||
|
as most Raspberry Pi boards only support 1 camera.
|
||||||
|
"""
|
||||||
self._setting_save_in_progress = False
|
self._setting_save_in_progress = False
|
||||||
self.camera_num = camera_num
|
self.camera_num = camera_num
|
||||||
self.camera_configs: dict[str, dict] = {}
|
self.camera_configs: dict[str, dict] = {}
|
||||||
|
|
@ -324,6 +335,11 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
self._picamera_lock = RLock()
|
self._picamera_lock = RLock()
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
|
"""Start streaming when the Thing context manager is opened.
|
||||||
|
|
||||||
|
This opens the picamera connection, initialises the camera, sets the
|
||||||
|
sensor_modes property, and then starts the streams.
|
||||||
|
"""
|
||||||
self._initialise_picamera()
|
self._initialise_picamera()
|
||||||
# populate sensor modes by reading the property
|
# populate sensor modes by reading the property
|
||||||
self.sensor_modes
|
self.sensor_modes
|
||||||
|
|
@ -360,7 +376,7 @@ class StreamingPiCamera2(BaseCamera):
|
||||||
self.start_streaming()
|
self.start_streaming()
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_value, traceback):
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
# Shut down the camera
|
"""Close the picamera connection when the Thing context manager is closed."""
|
||||||
self.stop_streaming()
|
self.stop_streaming()
|
||||||
with self._streaming_picamera() as cam:
|
with self._streaming_picamera() as cam:
|
||||||
cam.close()
|
cam.close()
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ RATIO = 0.2
|
||||||
|
|
||||||
|
|
||||||
class SimulatedCamera(BaseCamera):
|
class SimulatedCamera(BaseCamera):
|
||||||
"""A Thing representing an OpenCV camera."""
|
"""A Thing that simulates a camera for testing."""
|
||||||
|
|
||||||
_stage: Optional[BaseStage] = None
|
_stage: Optional[BaseStage] = None
|
||||||
_server: Optional[lt.ThingServer] = None
|
_server: Optional[lt.ThingServer] = None
|
||||||
|
|
@ -42,6 +42,16 @@ class SimulatedCamera(BaseCamera):
|
||||||
canvas_shape: tuple[int, int, int] = (3000, 4000, 3),
|
canvas_shape: tuple[int, int, int] = (3000, 4000, 3),
|
||||||
frame_interval: float = 0.1,
|
frame_interval: float = 0.1,
|
||||||
):
|
):
|
||||||
|
"""Initalise the simulated with settings for how images are generated.
|
||||||
|
|
||||||
|
:param shape: The shape (size) of the generated image.
|
||||||
|
:param glyph_shape: The size randomly positioned glyphs.
|
||||||
|
:param canvas_shape: The shape (size) of the canvas generated on initialisation
|
||||||
|
that images are cropped from. If this is too large the it uses resources,
|
||||||
|
but its size limits the range of motion of the simulation.
|
||||||
|
:param frame_interval: Nominally the time between frames on the MJPEG stream,
|
||||||
|
however the rate may be slower due to calculation time for focus.
|
||||||
|
"""
|
||||||
self.shape = shape
|
self.shape = shape
|
||||||
self.glyph_shape = glyph_shape
|
self.glyph_shape = glyph_shape
|
||||||
self.canvas_shape = canvas_shape
|
self.canvas_shape = canvas_shape
|
||||||
|
|
@ -143,12 +153,14 @@ class SimulatedCamera(BaseCamera):
|
||||||
return self.generate_image((pos["y"], pos["x"], pos["z"]))
|
return self.generate_image((pos["y"], pos["x"], pos["z"]))
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
|
"""Start the capture thread when the Thing context manager is opened."""
|
||||||
self._capture_enabled = True
|
self._capture_enabled = True
|
||||||
self._capture_thread = Thread(target=self._capture_frames)
|
self._capture_thread = Thread(target=self._capture_frames)
|
||||||
self._capture_thread.start()
|
self._capture_thread.start()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, _exc_type, _exc_value, _traceback):
|
def __exit__(self, _exc_type, _exc_value, _traceback):
|
||||||
|
"""Close the capture thread when the Thing context manager is closed."""
|
||||||
if self.stream_active:
|
if self.stream_active:
|
||||||
self._capture_enabled = False
|
self._capture_enabled = False
|
||||||
self._capture_thread.join()
|
self._capture_thread.join()
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,10 @@ class LoggingMoveWrapper:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, move_function: Callable):
|
def __init__(self, move_function: Callable):
|
||||||
|
"""Set the movement function to be wrapped.
|
||||||
|
|
||||||
|
:param move_function: the movement function to be wrapped
|
||||||
|
"""
|
||||||
self._move_function: Callable = move_function
|
self._move_function: Callable = move_function
|
||||||
self._current_position: Optional[CoordinateType] = None
|
self._current_position: Optional[CoordinateType] = None
|
||||||
self.clear_history()
|
self.clear_history()
|
||||||
|
|
@ -187,6 +191,7 @@ class CSMUncalibratedError(HTTPException):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
"""Customise the default error code and message of HTTPException."""
|
||||||
HTTPException.__init__(
|
HTTPException.__init__(
|
||||||
self,
|
self,
|
||||||
503,
|
503,
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,13 @@ class SmartScanThing(lt.Thing):
|
||||||
past scans.
|
past scans.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, scans_folder):
|
def __init__(self, scans_folder: str):
|
||||||
|
"""Initialise a SmartScanThing saving to and loading from the input directory.
|
||||||
|
|
||||||
|
:param scans_folder: This is the path to the directory where all scans will be
|
||||||
|
saved. Any scans already in this directory will be accessible through the
|
||||||
|
HTTP interface.
|
||||||
|
"""
|
||||||
self._scan_dir_manager = scan_directories.ScanDirectoryManager(scans_folder)
|
self._scan_dir_manager = scan_directories.ScanDirectoryManager(scans_folder)
|
||||||
self._preview_stitch_popen = None
|
self._preview_stitch_popen = None
|
||||||
self._preview_stitch_popen_lock = threading.Lock()
|
self._preview_stitch_popen_lock = threading.Lock()
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,22 @@ class DummyStage(BaseStage):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, step_time: float = 0.001, **kwargs):
|
def __init__(self, step_time: float = 0.001, **kwargs):
|
||||||
|
"""Initialise the Dummy stage, setting the step_time to adjust the speed.
|
||||||
|
|
||||||
|
:param step_time: The time in seconds per "motor" step. The default of 0.001
|
||||||
|
works well for the live simulation. For unit testing it is very slow
|
||||||
|
so the speed can be increased. Increasing it too far is problematic if
|
||||||
|
also doing computationally heavy tasks like simulated image blurring.
|
||||||
|
"""
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.step_time = step_time
|
self.step_time = step_time
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
|
"""Register the stage position when the Thing context manager is opened."""
|
||||||
self.instantaneous_position = self.position
|
self.instantaneous_position = self.position
|
||||||
|
|
||||||
def __exit__(self, _exc_type, _exc_value, _traceback):
|
def __exit__(self, _exc_type, _exc_value, _traceback):
|
||||||
pass
|
"""Nothing to do when the Thing context manager is closed."""
|
||||||
|
|
||||||
@lt.thing_action
|
@lt.thing_action
|
||||||
def move_relative(
|
def move_relative(
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ class SangaboardThing(BaseStage):
|
||||||
self.sangaboard_kwargs["port"] = port
|
self.sangaboard_kwargs["port"] = port
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
|
"""Connect to the sangaboard when the Thing context manager is opened."""
|
||||||
self._sangaboard = sangaboard.Sangaboard(**self.sangaboard_kwargs)
|
self._sangaboard = sangaboard.Sangaboard(**self.sangaboard_kwargs)
|
||||||
self._sangaboard_lock = threading.RLock()
|
self._sangaboard_lock = threading.RLock()
|
||||||
with self.sangaboard() as sb:
|
with self.sangaboard() as sb:
|
||||||
|
|
@ -51,6 +52,7 @@ class SangaboardThing(BaseStage):
|
||||||
self.update_position()
|
self.update_position()
|
||||||
|
|
||||||
def __exit__(self, _exc_type, _exc_value, _traceback):
|
def __exit__(self, _exc_type, _exc_value, _traceback):
|
||||||
|
"""Close the sangaboard connection when the Thing context manager is closed."""
|
||||||
with self.sangaboard() as sb:
|
with self.sangaboard() as sb:
|
||||||
sb.close()
|
sb.close()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue