Skip to content

Commit cc02702

Browse files
authored
Merge branch 'plotly:master' into patch-4
2 parents cd3bd07 + 4201fab commit cc02702

File tree

10 files changed

+1198
-683
lines changed

10 files changed

+1198
-683
lines changed

src/py/CHANGELOG.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
v1.1.0rc0
2+
- Improve verbosity of errors when starting kaleido improperly
3+
- Add new api functions start/stop_sync_server
4+
15
v1.0.0
26
- Add warning if using incompatible Plotly version
37

src/py/REFACTOR_INSTRUCTIONS.md

Lines changed: 0 additions & 9 deletions
This file was deleted.

src/py/kaleido/__init__.py

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,42 @@
44
Please see the README.md for more information and a quickstart.
55
"""
66

7-
import asyncio
8-
import queue
9-
from threading import Thread
7+
from __future__ import annotations
108

119
from choreographer.cli import get_chrome, get_chrome_sync
1210

11+
from . import _sync_server
1312
from ._page_generator import PageGenerator
1413
from .kaleido import Kaleido
1514

15+
_global_server = _sync_server.GlobalKaleidoServer()
16+
17+
18+
def start_sync_server(*args, **kwargs):
19+
"""
20+
Start a kaleido server which will process all sync generation requests.
21+
22+
Only one server can be started at a time.
23+
24+
This wrapper function takes the exact same arguments as kaleido.Kaleido().
25+
"""
26+
_global_server.open(*args, **kwargs)
27+
28+
29+
def stop_sync_server():
30+
"""Stop the kaleido server. It can be restarted."""
31+
_global_server.close()
32+
33+
1634
__all__ = [
1735
"Kaleido",
1836
"PageGenerator",
1937
"calc_fig",
2038
"calc_fig_sync",
2139
"get_chrome",
2240
"get_chrome_sync",
41+
"start_sync_server",
42+
"stop_sync_server",
2343
"write_fig",
2444
"write_fig_from_object",
2545
"write_fig_from_object_sync",
@@ -120,36 +140,25 @@ async def write_fig_from_object(
120140
)
121141

122142

123-
def _async_thread_run(func, args, kwargs):
124-
q = queue.Queue(maxsize=1)
125-
126-
def run(*args, **kwargs):
127-
# func is a closure
128-
try:
129-
q.put(asyncio.run(func(*args, **kwargs)))
130-
except BaseException as e: # noqa: BLE001
131-
q.put(e)
132-
133-
t = Thread(target=run, args=args, kwargs=kwargs)
134-
t.start()
135-
t.join()
136-
res = q.get()
137-
if isinstance(res, BaseException):
138-
raise res
139-
else:
140-
return res
141-
142-
143143
def calc_fig_sync(*args, **kwargs):
144144
"""Call `calc_fig` but blocking."""
145-
return _async_thread_run(calc_fig, args=args, kwargs=kwargs)
145+
if _global_server.is_running():
146+
return _global_server.call_function("calc_fig", *args, **kwargs)
147+
else:
148+
return _sync_server.oneshot_async_run(calc_fig, args=args, kwargs=kwargs)
146149

147150

148151
def write_fig_sync(*args, **kwargs):
149152
"""Call `write_fig` but blocking."""
150-
_async_thread_run(write_fig, args=args, kwargs=kwargs)
153+
if _global_server.is_running():
154+
_global_server.call_function("write_fig", *args, **kwargs)
155+
else:
156+
_sync_server.oneshot_async_run(write_fig, args=args, kwargs=kwargs)
151157

152158

153159
def write_fig_from_object_sync(*args, **kwargs):
154160
"""Call `write_fig_from_object` but blocking."""
155-
_async_thread_run(write_fig_from_object, args=args, kwargs=kwargs)
161+
if _global_server.is_running():
162+
_global_server.call_function("write_fig_from_object", *args, **kwargs)
163+
else:
164+
_sync_server.oneshot_async_run(write_fig_from_object, args=args, kwargs=kwargs)

src/py/kaleido/_fig_tools.py

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,36 @@
1+
from __future__ import annotations
2+
13
import glob
24
import re
35
from pathlib import Path
6+
from typing import TYPE_CHECKING, Any, Literal, TypedDict
47

58
import logistro
69

10+
if TYPE_CHECKING:
11+
from typing_extensions import TypeGuard
12+
713
_logger = logistro.getLogger(__name__)
814

915
# constants
1016
DEFAULT_EXT = "png"
1117
DEFAULT_SCALE = 1
1218
DEFAULT_WIDTH = 700
1319
DEFAULT_HEIGHT = 500
14-
SUPPORTED_FORMATS = ("png", "jpg", "jpeg", "webp", "svg", "json", "pdf") # pdf and eps
20+
SUPPORTED_FORMATS = ("png", "jpg", "jpeg", "webp", "svg", "json", "pdf")
21+
FormatString = Literal["png", "jpg", "jpeg", "webp", "svg", "json", "pdf"]
22+
23+
24+
def _assert_format(ext: str) -> TypeGuard[FormatString]:
25+
if ext not in SUPPORTED_FORMATS:
26+
raise ValueError(f"File format {ext} is not supported.")
27+
return True
28+
29+
30+
Figurish = Any # Be nice to make it more specific, dictionary or something
1531

1632

17-
def _is_figurish(o):
33+
def _is_figurish(o) -> TypeGuard[Figurish]:
1834
valid = hasattr(o, "to_dict") or (isinstance(o, dict) and "data" in o)
1935
if not valid:
2036
_logger.debug(
@@ -43,32 +59,49 @@ def _get_figure_dimensions(layout, width, height):
4359

4460

4561
def _get_format(extension):
46-
# Normalize format
4762
original_format = extension
4863
extension = extension.lower()
4964
if extension == "jpg":
50-
extension = "jpeg"
65+
return "jpeg"
5166

5267
if extension not in SUPPORTED_FORMATS:
53-
supported_formats_str = repr(list(SUPPORTED_FORMATS))
5468
raise ValueError(
5569
f"Invalid format '{original_format}'.\n"
56-
f" Supported formats: {supported_formats_str}",
70+
f" Supported formats: {SUPPORTED_FORMATS!s}",
5771
)
5872
return extension
5973

6074

61-
def to_spec(figure, layout_opts):
75+
# Input of to_spec
76+
class LayoutOpts(TypedDict, total=False):
77+
format: FormatString | None
78+
scale: int | float
79+
height: int | float
80+
width: int | float
81+
82+
83+
# Output of to_spec
84+
class Spec(TypedDict):
85+
format: FormatString
86+
width: int | float
87+
height: int | float
88+
scale: int | float
89+
data: Figurish
90+
91+
92+
def to_spec(figure, layout_opts: LayoutOpts) -> Spec:
6293
# Get figure layout
6394
layout = figure.get("layout", {})
6495

6596
for k, v in layout_opts.items():
6697
if k == "format":
6798
if v is not None and not isinstance(v, (str)):
68-
raise TypeError(f"{v} must be string or None")
99+
raise TypeError(
100+
f"{k} must be one of {SUPPORTED_FORMATS!s} or None, not {v}.",
101+
)
69102
elif k in ("scale", "height", "width"):
70103
if v is not None and not isinstance(v, (float, int)):
71-
raise TypeError(f"{v} must be numeric or None")
104+
raise TypeError(f"{k} must be numeric or None, not {v}.")
72105
else:
73106
raise AttributeError(f"Unknown key in layout options, {k}")
74107

@@ -90,7 +123,7 @@ def to_spec(figure, layout_opts):
90123
}
91124

92125

93-
def _next_filename(path, prefix, ext):
126+
def _next_filename(path, prefix, ext) -> str:
94127
default = 1 if (path / f"{prefix}.{ext}").exists() else 0
95128
re_number = re.compile(
96129
r"^" + re.escape(prefix) + r"\-(\d+)\." + re.escape(ext) + r"$",
@@ -106,7 +139,11 @@ def _next_filename(path, prefix, ext):
106139
return f"{prefix}.{ext}" if n == 1 else f"{prefix}-{n}.{ext}"
107140

108141

109-
def build_fig_spec(fig, path, opts): # noqa: C901
142+
def build_fig_spec( # noqa: C901, PLR0912
143+
fig: Figurish,
144+
path: Path | str | None,
145+
opts: LayoutOpts | None,
146+
) -> tuple[Spec, Path]:
110147
if not opts:
111148
opts = {}
112149

@@ -122,23 +159,27 @@ def build_fig_spec(fig, path, opts): # noqa: C901
122159
raise TypeError("Path should be a string or `pathlib.Path` object (or None)")
123160

124161
if path and path.suffix and not opts.get("format"):
125-
opts["format"] = path.suffix.lstrip(".")
162+
ext = path.suffix.lstrip(".")
163+
if _assert_format(ext): # not strict necessary if but helps typeguard
164+
opts["format"] = ext
126165

127166
spec = to_spec(fig, opts)
128167

129168
ext = spec["format"]
130-
full_path = None
169+
170+
full_path: Path | None = None
171+
directory: Path
131172
if not path:
132-
directory = Path()
173+
directory = Path() # use current Path
133174
elif path and (not path.suffix or path.is_dir()):
134175
if not path.is_dir():
135-
raise ValueError(f"Directories will not be created for you: {path}")
176+
raise ValueError(f"Directory {path} not found. Please create it.")
136177
directory = path
137178
else:
138179
full_path = path
139180
if not full_path.parent.is_dir():
140181
raise RuntimeError(
141-
f"Cannot reach path {path}. Are all directories created?",
182+
f"Cannot reach path {path.parent}. Are all directories created?",
142183
)
143184
if not full_path:
144185
_logger.debug("Looking for title")

src/py/kaleido/_mocker.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from pathlib import Path
1010
from pprint import pp
1111
from random import sample
12+
from typing import TypedDict
1213

1314
import logistro
1415
import orjson
@@ -39,8 +40,14 @@ def _get_jsons_in_paths(path: str | Path) -> list[Path]:
3940
raise TypeError("--input must be file or directory")
4041

4142

43+
class Param(TypedDict):
44+
name: str
45+
opts: dict[str, int | float]
46+
47+
4248
def _load_figures_from_paths(paths: list[Path]):
4349
# Set json
50+
params: list[Param]
4451
for path in paths:
4552
if path.is_file():
4653
with path.open(encoding="utf-8") as file:
@@ -80,7 +87,10 @@ def _load_figures_from_paths(paths: list[Path]):
8087
for f in formats:
8188
params.append(
8289
{
83-
"name": f"{path.stem}-{w}x{h}X{s}.{f}",
90+
"name": (
91+
f"{path.stem!s}-{w!s}"
92+
f"x{h!s}X{s!s}.{f!s}"
93+
),
8494
"opts": {
8595
"scale": s,
8696
"width": w,

src/py/kaleido/_page_generator.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ def __init__(self, *, plotly=None, mathjax=None, others=None, force_cdn=False):
6060
elif not plotly:
6161
try:
6262
# ruff: noqa: PLC0415
63-
import plotly as pltly # type: ignore [import-not-found]
63+
# is this the best way to do this? can't we use importlib?
64+
import plotly as pltly # type: ignore[import-untyped]
6465

6566
plotly_path = (
6667
Path(pltly.__file__).parent / "package_data" / "plotly.min.js"

0 commit comments

Comments
 (0)