pluot_core 0.0.1__tar.gz

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,71 @@
1
+ /target
2
+ target/
3
+ **/*.rs.bk
4
+ Cargo.lock
5
+ bin/
6
+ pkg/
7
+ wasm-pack.log
8
+ .DS_Store
9
+ node_modules/
10
+
11
+ crates/LICENSE
12
+ dist/
13
+ dist-tsc/
14
+ tsconfig.tsbuildinfo
15
+ data/out/
16
+
17
+ zarrs/
18
+
19
+ __pycache__/
20
+ .pytest_cache/
21
+ .ipynb_checkpoints/
22
+ *.so
23
+ .venv/
24
+ .ropeproject/
25
+ __marimo__/
26
+ bindings-python/pluot_widget/src/pluot_widget/static/
27
+ .Rproj.user
28
+
29
+ .astro/
30
+ npm-debug.log*
31
+ yarn-debug.log*
32
+ yarn-error.log*
33
+ pnpm-debug.log*
34
+ .env
35
+ .env.production
36
+
37
+ # Snapshot test outputs (reference snapshots are in tests/snapshots/)
38
+ current/
39
+ snaps-dirty/
40
+
41
+ skills-lock.json
42
+ .claude/settings.local.json
43
+ .claude/scheduled_tasks.lock
44
+ .claude/skills/autoresearch
45
+ .agents/
46
+
47
+ # R stuff
48
+ .Rhistory
49
+ .Rproj.user
50
+ *.o
51
+ AUTHORS
52
+ *.tar.gz
53
+ *.Rcheck
54
+
55
+
56
+ autoresearch/
57
+ circle_diffs/
58
+ text_diffs/
59
+
60
+ crates/pluot_core/src/vendored-fonts/*.ttf
61
+ bindings-js/core/src/vendored-fonts/*.ttf.js
62
+ bindings-r/src/.cargo/
63
+
64
+ .pluot_integration_test_*.sh
65
+
66
+
67
+ bindings-js/**/CHANGELOG.md
68
+ crates/**/CHANGELOG.md
69
+ RELEASE_NOTES.md
70
+
71
+ references/
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.5
2
+ Name: pluot_core
3
+ Version: 0.0.1
4
+ Summary: Shared utilities used by the pluot and pluot_widget packages
5
+ Project-URL: repository, https://github.com/keller-mark/pluot
6
+ Author: Mark Keller
7
+ License-Expression: Apache-2.0
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: numpy>=2
10
+ Requires-Dist: obstore
11
+ Requires-Dist: zarr>=3
12
+ Description-Content-Type: text/markdown
13
+
14
+ This pluot_core package will contain all shared functionality that is used by the pluot and pluot_widget packages, such as zarr and font stuff and viewport utilities.
@@ -0,0 +1 @@
1
+ This pluot_core package will contain all shared functionality that is used by the pluot and pluot_widget packages, such as zarr and font stuff and viewport utilities.
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pluot_core"
7
+ version = "0.0.1"
8
+ requires-python = ">=3.12"
9
+ license = "Apache-2.0"
10
+ authors = [
11
+ {name = "Mark Keller"}
12
+ ]
13
+ readme = "README.md"
14
+ description = "Shared utilities used by the pluot and pluot_widget packages"
15
+ dependencies = [
16
+ "numpy>=2",
17
+ "zarr>=3",
18
+ "obstore",
19
+ ]
20
+
21
+ [project.urls]
22
+ repository = "https://github.com/keller-mark/pluot"
File without changes
@@ -0,0 +1,138 @@
1
+ import os
2
+ import re
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ # TODO: use matplotlib's FontManager?
7
+ # Reference: https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/font_manager.py
8
+ # But this would introduce a matplotlib dependency.
9
+ # Is there a more lightweight alternative?
10
+
11
+ # Optional path overrides: font_family -> file path.
12
+ # Takes priority over system font detection.
13
+ _FONT_OVERRIDES: dict[str, str] = {}
14
+
15
+ def register_font(font_name: str, path: str) -> None:
16
+ """Override the file path used for a named font. Takes priority over system detection."""
17
+ _FONT_OVERRIDES[font_name] = path
18
+
19
+ def _normalize(name: str) -> str:
20
+ return re.sub(r'[\s\-_]', '', name).lower()
21
+
22
+
23
+ # OS Font paths
24
+ try:
25
+ _HOME = Path.home()
26
+ except Exception: # Exceptions thrown by home() are not specified...
27
+ _HOME = Path(os.devnull) # Just an arbitrary path with no children.
28
+ MSFolders = \
29
+ r'Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders'
30
+ MSFontDirectories = [
31
+ r'SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts',
32
+ r'SOFTWARE\Microsoft\Windows\CurrentVersion\Fonts']
33
+ MSUserFontDirectories = [
34
+ str(_HOME / 'AppData/Local/Microsoft/Windows/Fonts'),
35
+ str(_HOME / 'AppData/Roaming/Microsoft/Windows/Fonts'),
36
+ ]
37
+ X11FontDirectories = [
38
+ # an old standard installation point
39
+ "/usr/X11R6/lib/X11/fonts/TTF/",
40
+ "/usr/X11/lib/X11/fonts",
41
+ # here is the new standard location for fonts
42
+ "/usr/share/fonts/",
43
+ # documented as a good place to install new fonts
44
+ "/usr/local/share/fonts/",
45
+ # common application, not really useful
46
+ "/usr/lib/openoffice/share/fonts/truetype/",
47
+ # user fonts
48
+ str((Path(os.environ.get('XDG_DATA_HOME') or _HOME / ".local/share"))
49
+ / "fonts"),
50
+ str(_HOME / ".fonts"),
51
+ ]
52
+ OSXFontDirectories = [
53
+ "/Library/Fonts/",
54
+ "/Network/Library/Fonts/",
55
+ "/System/Library/Fonts/",
56
+ # fonts installed via MacPorts
57
+ "/opt/local/share/fonts",
58
+ # user fonts
59
+ str(_HOME / "Library/Fonts"),
60
+ ]
61
+
62
+ def _system_font_dirs() -> list[Path]:
63
+ if sys.platform == 'darwin':
64
+ return [Path(d) for d in OSXFontDirectories]
65
+ elif sys.platform == 'win32':
66
+ return [Path(d) for d in MSUserFontDirectories]
67
+ else: # Linux / other
68
+ return [Path(d) for d in X11FontDirectories]
69
+
70
+ def _find_system_font(font_name: str) -> str | None:
71
+ norm_name = _normalize(font_name)
72
+ for font_dir in _system_font_dirs():
73
+ if not font_dir.exists():
74
+ continue
75
+ for path in font_dir.rglob('*'):
76
+ if path.suffix.lower() not in ('.ttf', '.otf'):
77
+ continue
78
+ if _normalize(path.stem) == norm_name:
79
+ return str(path)
80
+ return None
81
+
82
+ # Cache results of _find_system_font to avoid redundant directory scans.
83
+ _FONT_PATH_CACHE: dict[str, str | None] = {}
84
+
85
+ def _resolve_font_path(font_name: str) -> str | None:
86
+ """Return the file path for a font.
87
+
88
+ Lookup order:
89
+ 1. Explicit override registered via register_font().
90
+ 2. System font detection.
91
+ """
92
+ if font_name in _FONT_OVERRIDES:
93
+ return _FONT_OVERRIDES[font_name]
94
+ if font_name not in _FONT_PATH_CACHE:
95
+ _FONT_PATH_CACHE[font_name] = _find_system_font(font_name)
96
+ return _FONT_PATH_CACHE[font_name]
97
+
98
+ def _key_to_font_family(key: str) -> str:
99
+ """Extract the font family from a zarr-style key.
100
+
101
+ Keys use the format "{family}/{style}/{weight}.ttf". The family is the
102
+ first path segment. A leading "/" is stripped defensively if present.
103
+ """
104
+ # Strip any accidental leading slash then drop trailing .ttf/.otf.
105
+ name = key.lstrip('/')
106
+ if name.lower().endswith('.ttf') or name.lower().endswith('.otf'):
107
+ name = name[:-4]
108
+ # First segment is the family; remaining segments are style and weight.
109
+ return name.split('/')[0]
110
+
111
+ class _BytesBuffer:
112
+ """Minimal buffer wrapper compatible with zarr.py's `.to_bytes()` protocol."""
113
+ def __init__(self, data: bytes):
114
+ self._data = data
115
+ def to_bytes(self) -> bytes:
116
+ return self._data
117
+
118
+ class FontStore:
119
+ """
120
+ Store for fonts, registered as '__fonts__' in GLOBAL_STORES.
121
+ Rust requests fonts via zarr_get_status / zarr_get with store_name='__fonts__'
122
+ and key='{family}/{weight}/{style}.ttf'. The get_sync method is called by
123
+ zarr_get_status to eagerly resolve synchronous results without waiting for
124
+ an async call.
125
+ """
126
+
127
+ def get_sync(self, key: str) -> bytes:
128
+ """Synchronously return font bytes, or raise if unavailable."""
129
+ font_family = _key_to_font_family(key)
130
+ path = _resolve_font_path(font_family)
131
+ if path is None:
132
+ raise FileNotFoundError(f"Font not found: {font_family}")
133
+ with open(path, 'rb') as f:
134
+ return f.read()
135
+
136
+ async def get(self, key: str, prototype=None, byte_range=None) -> _BytesBuffer:
137
+ """Async fallback for zarr_get cache-miss path."""
138
+ return _BytesBuffer(self.get_sync(key))
@@ -0,0 +1,68 @@
1
+ # Copied from zarr-python
2
+ # Reference: https://github.com/zarr-developers/zarr-python/blob/fe229107f9915f05817f7a664d3550695ff9ca44/src/zarr/testing/stateful.py#L438
3
+
4
+ import builtins
5
+ from typing import Any
6
+ import zarr
7
+ from zarr.abc.store import Store
8
+ from zarr.core.buffer import Buffer, BufferPrototype
9
+
10
+
11
+ class SyncStoreWrapper(zarr.core.sync.SyncMixin):
12
+ def __init__(self, store: Store) -> None:
13
+ """Synchronous Store wrapper
14
+
15
+ This class holds synchronous methods that map to async methods of Store classes.
16
+ The synchronous wrapper is needed because hypothesis' stateful testing infra does
17
+ not support asyncio so we redefine sync versions of the Store API.
18
+ https://github.com/HypothesisWorks/hypothesis/issues/3712#issuecomment-1668999041
19
+ """
20
+ self.store = store
21
+
22
+ @property
23
+ def read_only(self) -> bool:
24
+ return self.store.read_only
25
+
26
+ def set(self, key: str, data_buffer: Buffer) -> None:
27
+ return self._sync(self.store.set(key, data_buffer))
28
+
29
+ def list(self) -> builtins.list[str]:
30
+ return self._sync_iter(self.store.list())
31
+
32
+ def get(self, key: str, prototype: BufferPrototype, **kwargs) -> Buffer | None:
33
+ return self._sync(self.store.get(key, prototype=prototype, **kwargs))
34
+
35
+ def get_partial_values(
36
+ self, key_ranges: builtins.list[Any], prototype: BufferPrototype
37
+ ) -> builtins.list[Buffer | None]:
38
+ return self._sync(self.store.get_partial_values(prototype=prototype, key_ranges=key_ranges))
39
+
40
+ def delete(self, path: str) -> None:
41
+ return self._sync(self.store.delete(path))
42
+
43
+ def is_empty(self, prefix: str) -> bool:
44
+ return self._sync(self.store.is_empty(prefix=prefix))
45
+
46
+ def clear(self) -> None:
47
+ return self._sync(self.store.clear())
48
+
49
+ def exists(self, key: str) -> bool:
50
+ return self._sync(self.store.exists(key))
51
+
52
+ def list_dir(self, prefix: str) -> None:
53
+ raise NotImplementedError
54
+
55
+ def list_prefix(self, prefix: str) -> None:
56
+ raise NotImplementedError
57
+
58
+ @property
59
+ def supports_listing(self) -> bool:
60
+ return self.store.supports_listing
61
+
62
+ @property
63
+ def supports_writes(self) -> bool:
64
+ return self.store.supports_writes
65
+
66
+ @property
67
+ def supports_deletes(self) -> bool:
68
+ return self.store.supports_deletes
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+ from typing import Literal, Optional
3
+ from dataclasses import dataclass, field
4
+ import numpy as np
5
+
6
+ # TODO: auto-generate these types from the Rust side: https://github.com/keller-mark/pluot/issues/133
7
+ AspectRatioMode = Literal["Ignore", "Contain", "Cover"]
8
+ AspectRatioAlignmentMode = Literal["Center", "Start", "End"]
9
+
10
+
11
+ @dataclass
12
+ class Margins:
13
+ margin_top: float = 0.0
14
+ margin_right: float = 0.0
15
+ margin_bottom: float = 0.0
16
+ margin_left: float = 0.0
17
+
18
+
19
+ @dataclass
20
+ class ViewportParams:
21
+ width: float
22
+ height: float
23
+ aspect_ratio_mode: AspectRatioMode
24
+ aspect_ratio_alignment_mode: AspectRatioAlignmentMode
25
+ margins: Optional[Margins] = field(default=None)
26
+
27
+
28
+ @dataclass
29
+ class Bounds:
30
+ # Each value is optional.
31
+ # When an entire dimension is omitted (X or Y),
32
+ # use the current camera settings for that dimension.
33
+ # When a single value is omitted (e.g., x_min), ensure the resulting camera matrix keeps this boundary unchanged.
34
+ x_min: Optional[float] = None
35
+ x_max: Optional[float] = None
36
+ y_min: Optional[float] = None
37
+ y_max: Optional[float] = None
38
+
39
+
40
+ def _get_scales_and_align_translations(viewport_params: ViewportParams):
41
+ margins = viewport_params.margins
42
+ margin_top = margins.margin_top if margins else 0.0
43
+ margin_right = margins.margin_right if margins else 0.0
44
+ margin_bottom = margins.margin_bottom if margins else 0.0
45
+ margin_left = margins.margin_left if margins else 0.0
46
+
47
+ layer_w = viewport_params.width - margin_left - margin_right
48
+ layer_h = viewport_params.height - margin_top - margin_bottom
49
+ layer_aspect_ratio = layer_w / layer_h
50
+
51
+ x_scale = 1.0
52
+ y_scale = 1.0
53
+ if viewport_params.aspect_ratio_mode == "Contain":
54
+ if layer_aspect_ratio > 1.0:
55
+ x_scale = layer_aspect_ratio
56
+ elif layer_aspect_ratio < 1.0:
57
+ y_scale = 1.0 / layer_aspect_ratio
58
+ elif viewport_params.aspect_ratio_mode == "Cover":
59
+ if layer_aspect_ratio > 1.0:
60
+ y_scale = 1.0 / layer_aspect_ratio
61
+ elif layer_aspect_ratio < 1.0:
62
+ x_scale = layer_aspect_ratio
63
+
64
+ x_align_translation = 0.0
65
+ y_align_translation = 0.0
66
+ if viewport_params.aspect_ratio_alignment_mode == "Start":
67
+ x_align_translation = x_scale - 1.0
68
+ y_align_translation = y_scale - 1.0
69
+ elif viewport_params.aspect_ratio_alignment_mode == "End":
70
+ x_align_translation = 1.0 - x_scale
71
+ y_align_translation = 1.0 - y_scale
72
+
73
+ return x_scale, y_scale, x_align_translation, y_align_translation
74
+
75
+
76
+ def get_bounds(camera_matrix: np.ndarray, viewport_params: ViewportParams) -> Bounds:
77
+ """Calculate the visible data range based on camera view and viewport parameters."""
78
+ zoom_x = camera_matrix[0]
79
+ zoom_y = camera_matrix[5]
80
+ translate_x = camera_matrix[12]
81
+ translate_y = camera_matrix[13]
82
+
83
+ x_scale, y_scale, x_align_translation, y_align_translation = _get_scales_and_align_translations(viewport_params)
84
+
85
+ x_adj = x_scale - 1.0
86
+ y_adj = y_scale - 1.0
87
+
88
+ x_min = ((-translate_x - 1.0 - x_adj + x_align_translation) / zoom_x + 1.0) / 2.0
89
+ x_max = ((-translate_x + 1.0 + x_adj + x_align_translation) / zoom_x + 1.0) / 2.0
90
+ y_min = ((-translate_y - 1.0 - y_adj + y_align_translation) / zoom_y + 1.0) / 2.0
91
+ y_max = ((-translate_y + 1.0 + y_adj + y_align_translation) / zoom_y + 1.0) / 2.0
92
+
93
+ return Bounds(x_min=x_min, x_max=x_max, y_min=y_min, y_max=y_max)
94
+
95
+
96
+ def get_camera_matrix_from_bounds(bounds: Bounds, prev_camera_matrix: np.ndarray, viewport_params: ViewportParams) -> np.ndarray:
97
+ """Given data bounds, compute the corresponding camera matrix.
98
+ Missing bound values are filled in from prev_camera_matrix.
99
+ """
100
+ current_bounds = get_bounds(prev_camera_matrix, viewport_params)
101
+ x_min = bounds.x_min if bounds.x_min is not None else current_bounds.x_min
102
+ x_max = bounds.x_max if bounds.x_max is not None else current_bounds.x_max
103
+ y_min = bounds.y_min if bounds.y_min is not None else current_bounds.y_min
104
+ y_max = bounds.y_max if bounds.y_max is not None else current_bounds.y_max
105
+
106
+ x_scale, y_scale, x_align_translation, y_align_translation = _get_scales_and_align_translations(viewport_params)
107
+
108
+ x_adj = x_scale - 1.0
109
+ y_adj = y_scale - 1.0
110
+
111
+ x_range = x_max - x_min
112
+ y_range = y_max - y_min
113
+
114
+ zoom_x = (1.0 + x_adj) / x_range
115
+ zoom_y = (1.0 + y_adj) / y_range
116
+
117
+ # When aspect ratio is ignored, zoom each axis independently.
118
+ # Otherwise take the minimum so all requested data fits within the viewport.
119
+ if viewport_params.aspect_ratio_mode != "Ignore":
120
+ zoom_x = zoom_y = min(zoom_x, zoom_y)
121
+
122
+ # Invert the get_bounds translation equations:
123
+ # min + max = (-translate + align) / zoom + 1.0
124
+ # So: translate = align - zoom * ((min + max) - 1.0)
125
+ translate_x = x_align_translation - zoom_x * ((x_min + x_max) - 1.0)
126
+ translate_y = y_align_translation - zoom_y * ((y_min + y_max) - 1.0)
127
+
128
+ return np.array([
129
+ zoom_x, 0.0, 0.0, 0.0,
130
+ 0.0, zoom_y, 0.0, 0.0,
131
+ 0.0, 0.0, 1.0, 0.0,
132
+ translate_x, translate_y, 0.0, 1.0,
133
+ ], dtype=np.float32)
@@ -0,0 +1,121 @@
1
+ def store_instance_to_metadata(store) -> dict:
2
+ """Derive portable ``ZarrStoreInfo`` metadata from a zarr-python store instance.
3
+
4
+ The result mirrors the Rust ``ZarrStoreInfo`` JSON (see
5
+ ``crates/pluot_core/src/params.rs``): an adjacently-tagged ``store_type`` /
6
+ ``store_params`` pair plus an optional ``store_extensions`` list.
7
+
8
+ Resolution order:
9
+ 1. A wrapper store may declare its own metadata via a ``store_metadata``
10
+ attribute (see :func:`store_with_metadata`), which already passes the
11
+ inner store's metadata through and layers on any extension.
12
+ 2. A ``LocalStore`` exposes ``.root`` -> ``LocalStore``.
13
+ 3. An fsspec/remote-backed store yields a URL -> ``HttpStore``.
14
+ 4. Otherwise fall back to a ``MemoryStore`` descriptor. The instance is
15
+ still usable at render time (it is registered by name in
16
+ ``GLOBAL_STORES``), but its data is not reconstructable from metadata.
17
+ """
18
+ declared = getattr(store, "store_metadata", None)
19
+ if isinstance(declared, dict) and "store_type" in declared:
20
+ return declared
21
+
22
+ # LocalStore exposes `.root` (a path).
23
+ root = getattr(store, "root", None)
24
+ if root is not None:
25
+ return {
26
+ "store_type": "LocalStore",
27
+ "store_params": {"path": str(root)},
28
+ "store_extensions": None,
29
+ }
30
+
31
+ # Remote / fsspec-backed stores: derive a URL where possible.
32
+ url = _derive_store_url(store)
33
+ if url is not None:
34
+ return {
35
+ "store_type": "HttpStore",
36
+ "store_params": {"url": url},
37
+ "store_extensions": None,
38
+ }
39
+
40
+ return {
41
+ "store_type": "MemoryStore",
42
+ "store_params": {
43
+ "message": f"In-memory or custom store ({type(store).__name__})"
44
+ },
45
+ "store_extensions": None,
46
+ }
47
+
48
+
49
+ # Registry of store-extension appliers used by store_metadata_to_instance to
50
+ # reconstruct virtual-zarr wrapper stores (the inverse of the store_extensions
51
+ # recorded by store_instance_to_metadata). Appliers are opt-in so this package
52
+ # need not depend on every virtual-zarr implementation.
53
+ _STORE_EXTENSION_APPLIERS: dict = {}
54
+
55
+
56
+ def register_store_extension(extension: str, applier) -> None:
57
+ """Register the applier used to reconstruct a ``ZarrStoreExtension`` wrapper.
58
+
59
+ ``applier`` takes a base store and returns a wrapped store (e.g. one that
60
+ virtualizes OME-TIFF data as zarr).
61
+ """
62
+ _STORE_EXTENSION_APPLIERS[extension] = applier
63
+
64
+
65
+ def store_metadata_to_instance(info: dict):
66
+ """Construct a concrete zarr-python store instance from ``ZarrStoreInfo`` metadata.
67
+
68
+ The inverse of :func:`store_instance_to_metadata`:
69
+
70
+ - ``HttpStore`` -> a remote fsspec-backed store for the URL;
71
+ - ``LocalStore`` -> a ``zarr.storage.LocalStore`` for the path;
72
+ - ``MemoryStore`` -> raises (an in-memory store has no portable
73
+ representation and must be provided directly).
74
+
75
+ Any ``store_extensions`` are then applied outermost-last using appliers
76
+ registered via :func:`register_store_extension`.
77
+ """
78
+ store_type = info["store_type"]
79
+ params = info.get("store_params") or {}
80
+
81
+ if store_type == "HttpStore":
82
+ store = http_store_from_url(params["url"])
83
+ elif store_type == "LocalStore":
84
+ from zarr.storage import LocalStore
85
+ store = LocalStore(params["path"])
86
+ elif store_type == "MemoryStore":
87
+ raise ValueError(
88
+ "Cannot reconstruct an in-memory store from metadata "
89
+ f"({params.get('message')!r}); provide the store instance directly."
90
+ )
91
+ else:
92
+ raise ValueError(f"Unknown store_type: {store_type!r}")
93
+
94
+ for ext in info.get("store_extensions") or []:
95
+ applier = _STORE_EXTENSION_APPLIERS.get(ext)
96
+ if applier is None:
97
+ raise ValueError(
98
+ f"No applier registered for store extension {ext!r}. "
99
+ "Register one via register_store_extension()."
100
+ )
101
+ store = applier(store)
102
+ return store
103
+
104
+
105
+ def http_store_from_url(url: str):
106
+ """Construct a remote (fsspec-backed) zarr store from a URL."""
107
+ from obstore.store import HTTPStore
108
+ from zarr.storage import ObjectStore
109
+
110
+ obs_store = HTTPStore.from_url(url)
111
+ return ObjectStore(obs_store, read_only=True)
112
+
113
+
114
+ def _derive_store_url(store):
115
+ """Best-effort extraction of a URL from a remote obstore-backed zarr store."""
116
+ # zarr.storage.ObjectStore's .store should contain an obstore HTTPStore.
117
+ obs_store = getattr(store, "store", None)
118
+ url = getattr(obs_store, "url", None)
119
+ if isinstance(url, str) and "://" in url:
120
+ return url
121
+ return None