Skip to content

Commit 56d3d80

Browse files
committed
Add many additional tests
1 parent 0810f17 commit 56d3d80

File tree

15 files changed

+3042
-74
lines changed

15 files changed

+3042
-74
lines changed

.gitattributes

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# SCM syntax highlighting & preventing 3-way merges
2+
pixi.lock merge=binary linguist-language=YAML linguist-generated=true

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,7 @@ venv/
22
*.egg*
33
*.pyc
44
_version.py
5+
# pixi environments
6+
.pixi/*
7+
!.pixi/config.toml
8+
.htmlcov/

.pre-commit-config.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
exclude: ^tests/outputs/
2+
13
repos:
24
- repo: https://github.com/pre-commit/pre-commit-hooks
35
rev: v5.0.0

pixi.lock

Lines changed: 1220 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ classifiers = [
1414
description = "CLI utility for auto-creating phoebus engineering screens from EPICS db templates"
1515
dependencies = [
1616
"phoebusgen",
17+
"rectangle-packer",
1718
"epicsdbtools@git+https://github.com/jwlodek/epicsdbtools",
1819
]
1920
dynamic = ["version"]
@@ -117,3 +118,30 @@ exclude = [
117118

118119
[tool.importlinter]
119120
root_package = "epicsdb2bob"
121+
122+
[tool.pixi.workspace]
123+
channels = ["conda-forge"]
124+
platforms = ["linux-64"]
125+
126+
[tool.pixi.pypi-dependencies]
127+
epicsdb2bob = { path = ".", editable = true }
128+
129+
[tool.pixi.environments]
130+
default = { solve-group = "default" }
131+
dev = { features = ["dev"], solve-group = "default" }
132+
133+
[tool.pixi.tasks]
134+
test = "pytest"
135+
test-w-cov = "pytest --cov --cov-report=html"
136+
lint = "pre-commit run --all-files"
137+
138+
[tool.pixi.dependencies]
139+
pytest = ">=9.0.1,<10"
140+
pre-commit = ">=4.4.0,<5"
141+
ruff = ">=0.14.6,<0.15"
142+
tox = ">=4.32.0,<5"
143+
pytest-cov = ">=7.0.0,<8"
144+
phoebusgen = ">=3.1.0,<4"
145+
rectangle-packer = ">=2.0.4,<3"
146+
python = ">=3.12.12,<3.13"
147+
import-linter = ">=2.7,<3"

src/epicsdb2bob/__main__.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010
from .bobfile_gen import generate_bobfile_for_db, generate_bobfile_for_substitution
1111
from .config import EPICSDB2BOBConfig
1212
from .palettes import BUILTIN_PALETTES
13-
from .parser import find_epics_dbs_and_templates, find_epics_subs
13+
from .utils import (
14+
find_bobfiles_in_search_path,
15+
find_epics_dbs_and_templates,
16+
find_epics_subs,
17+
)
1418

1519
__all__ = ["main"]
1620

@@ -158,21 +162,14 @@ def main() -> None:
158162
epicsdbtools_logger.setLevel(logging.DEBUG)
159163
logger.debug("Loaded configuration from .epicsdb2bob.yml")
160164
else:
165+
args.palette = BUILTIN_PALETTES[args.palette]
161166
config = EPICSDB2BOBConfig()
162167
logger.debug("No configuration file found, using defaults.")
163168
for key, value in vars(args).items():
164169
if value is not None:
165170
setattr(config, key, value)
166171

167-
written_bobfiles: dict[str, Path] = {}
168-
169-
for bobfile_dir in config.bobfile_search_path:
170-
for dirpath, _, filenames in os.walk(bobfile_dir):
171-
for filename in filenames:
172-
if filename.endswith((".bob", ".opi")):
173-
full_path = Path(os.path.join(dirpath, filename))
174-
logger.info(f"Found additional bob/opi file: {full_path}")
175-
written_bobfiles[filename] = full_path
172+
written_bobfiles = find_bobfiles_in_search_path(config.bobfile_search_path)
176173

177174
macros = (
178175
{macro.split("=")[0]: macro.split("=")[1] for macro in args.macros}

src/epicsdb2bob/bobfile_gen.py

Lines changed: 62 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
TitleBarFormat,
3636
)
3737
from .palettes import BLACK, WHITE
38+
from .utils import pack_close_to_square
3839

3940
logger = logging.getLogger("epicsdb2bob")
4041

@@ -151,26 +152,31 @@ def add_widget_for_record(
151152

152153

153154
def add_title_bar(
154-
name: str, config: EPICSDB2BOBConfig, title_bar_width: int
155+
name: str,
156+
config: EPICSDB2BOBConfig,
157+
title_bar_width: int,
158+
top_level_titlebar: bool = False,
155159
) -> Label | None:
156-
if config.title_bar_format == TitleBarFormat.NONE:
160+
title_bar_format = (
161+
config.title_bar_format if not top_level_titlebar else TitleBarFormat.FULL
162+
)
163+
164+
if title_bar_format == TitleBarFormat.NONE:
157165
return None
158166

159167
title_bar = Label(
160-
short_uuid(),
168+
"TitleBar",
161169
name,
162-
config.widget_offset
163-
if config.title_bar_format == TitleBarFormat.MINIMAL
164-
else 0,
170+
config.widget_offset if title_bar_format == TitleBarFormat.MINIMAL else 0,
165171
0,
166172
title_bar_width,
167-
config.title_bar_heights[config.title_bar_format],
173+
config.title_bar_heights[title_bar_format],
168174
)
169175
title_bar.foreground_color(*WHITE)
170-
if config.title_bar_format == TitleBarFormat.FULL:
176+
if title_bar_format == TitleBarFormat.FULL:
171177
title_bar.font_size(config.font_size * 2)
172178
title_bar.horizontal_alignment_center()
173-
elif config.title_bar_format == TitleBarFormat.MINIMAL:
179+
elif title_bar_format == TitleBarFormat.MINIMAL:
174180
title_bar.auto_size()
175181
title_bar.font_size(config.font_size + 2)
176182
title_bar.border_width(2)
@@ -328,7 +334,11 @@ def generate_bobfile_for_db(
328334
else:
329335
screen_height = current_y_pos + config.widget_offset
330336

331-
title_bar = add_title_bar(name, config, screen_width - config.widget_offset)
337+
title_bar = add_title_bar(
338+
name.replace("_", " ").replace("-", " "),
339+
config,
340+
screen_width - config.widget_offset,
341+
)
332342
if title_bar:
333343
widget_counters[Label] = widget_counters.get(Label, 0) + 1
334344
title_bar.name(f"Label_{widget_counters[Label]}")
@@ -340,7 +350,7 @@ def generate_bobfile_for_db(
340350
screen_height - int(config.title_bar_heights[config.title_bar_format] / 2)
341351
)
342352

343-
screen.background_color(*config.background_color)
353+
screen.background_color(*config.palette.screen_bg)
344354

345355
screen.height(screen_height)
346356
screen.width(screen_width)
@@ -372,22 +382,17 @@ def generate_bobfile_for_substitution(
372382
"""
373383
Generate a BOB file for a substitution.
374384
"""
385+
substitution_name.replace("_", " ").replace("-", " ").title()
375386
screen = Screen(substitution_name)
376387
screen.background_color(*config.background_color)
377388

378-
screen_width = 0
379-
max_col_width = 0
380-
hit_max_y_pos = False
381-
382-
current_x_pos = config.widget_offset
383-
current_y_pos = (
384-
config.widget_offset + config.title_bar_heights[config.title_bar_format]
385-
)
386389
launcher_buttons: dict[str, ActionButton] = {}
387390

388391
logger.info(f"Generating screen for substitution: {substitution_name}")
389392
logger.debug(f"Found bobfiles: {found_bobfiles}")
390393

394+
embed_rects: dict[Widget, tuple[int, int]] = {}
395+
391396
for template in substitution:
392397
template_instances = substitution[template]
393398
logger.info(f"Processing template: {template}")
@@ -402,33 +407,20 @@ def generate_bobfile_for_substitution(
402407
)
403408
embed_height = embed_raw_height + config.widget_offset
404409
embed_width = embed_raw_width + config.widget_offset
405-
if (
406-
current_y_pos + embed_height
407-
> config.max_screen_height
408-
+ config.title_bar_heights[TitleBarFormat.FULL]
409-
):
410-
current_y_pos = (
411-
config.widget_offset
412-
+ config.title_bar_heights[TitleBarFormat.FULL]
413-
)
414-
current_x_pos += max_col_width + config.widget_offset
415-
max_col_width = 0
416410

417411
embedded_display = EmbeddedDisplay(
418412
short_uuid(),
419413
template_to_bob(template),
420-
current_x_pos,
421-
current_y_pos,
414+
0,
415+
0,
422416
embed_width,
423417
embed_height,
424418
)
425-
current_y_pos += embed_height + config.widget_offset
426419

427-
if embed_width > max_col_width:
428-
max_col_width = embed_width
420+
embed_rects[embedded_display] = (embed_width, embed_height)
421+
429422
for macro in instance:
430423
embedded_display.macro(macro, instance[macro])
431-
screen.add_widget(embedded_display)
432424

433425
elif template in launcher_buttons:
434426
launcher_buttons[template].action_open_display(
@@ -439,49 +431,59 @@ def generate_bobfile_for_substitution(
439431
)
440432
else:
441433
logger.info(f"Creating launcher button for template: {template}")
434+
442435
launcher_buttons[template] = ActionButton(
443436
short_uuid(),
444437
os.path.splitext(template)[0],
445438
"",
446-
current_x_pos,
447-
current_y_pos,
439+
0,
440+
0,
448441
config.default_widget_width,
449442
config.default_widget_height,
450443
)
444+
451445
launcher_buttons[template].action_open_display(
452446
template_to_bob(template),
453447
"tab",
454448
f"{os.path.splitext(template)[0]} {i + 1}",
455449
instance,
456450
)
457-
screen.add_widget(launcher_buttons[template])
458-
current_y_pos += config.default_widget_height + config.widget_offset
459-
460-
if config.default_widget_width > max_col_width:
461-
max_col_width = config.default_widget_width
462-
463-
if (
464-
current_y_pos
465-
> config.max_screen_height
466-
+ config.title_bar_heights[TitleBarFormat.FULL]
467-
):
468-
hit_max_y_pos = True
469-
current_y_pos = (
470-
config.widget_offset
471-
+ config.title_bar_heights[TitleBarFormat.FULL]
472-
)
473-
current_x_pos += max_col_width + config.widget_offset
474-
max_col_width = 0
451+
embed_rects[launcher_buttons[template]] = (
452+
config.default_widget_width + config.widget_offset,
453+
config.default_widget_height + config.widget_offset,
454+
)
475455

476-
screen_height = current_y_pos + config.widget_offset
477-
if hit_max_y_pos:
478-
screen_height = config.max_screen_height + config.widget_offset
479-
screen_width = current_x_pos + max_col_width + config.widget_offset
456+
packed_x_y_embeds = pack_close_to_square(
457+
list(embed_rects.values()),
458+
config.max_screen_height,
459+
padding=config.widget_offset,
460+
)
461+
462+
embed_stop_positions = [
463+
(pos[0] + size[0], pos[1] + size[1])
464+
for pos, size in zip(packed_x_y_embeds, embed_rects.values(), strict=False)
465+
]
466+
screen_width = max([pos[0] for pos in embed_stop_positions], default=0)
467+
screen_height = max([pos[1] for pos in embed_stop_positions], default=0)
468+
screen_height = screen_height + 5 * config.widget_offset
469+
470+
for i, (xy_position, embed) in enumerate(
471+
zip(packed_x_y_embeds, embed_rects.keys(), strict=False)
472+
):
473+
embed.x(xy_position[0])
474+
embed.y(
475+
xy_position[1]
476+
+ config.title_bar_heights[config.title_bar_format]
477+
+ 3 * config.widget_offset
478+
)
479+
embed.name(f"{embed.__class__.__name__}_{i + 1}")
480+
screen.add_widget(embed)
480481

481482
title_bar = add_title_bar(
482483
substitution_name,
483484
config,
484485
screen_width - config.widget_offset,
486+
top_level_titlebar=True,
485487
)
486488
if title_bar:
487489
screen.add_widget(title_bar)

src/epicsdb2bob/parser.py renamed to src/epicsdb2bob/utils.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from collections import OrderedDict
44
from pathlib import Path
55

6+
import rpack
67
from epicsdbtools import (
78
Database,
89
LoadIncludesStrategy,
@@ -93,3 +94,59 @@ def find_epics_subs(search_path: Path) -> dict[str, dict[str, list[dict[str, str
9394
)
9495

9596
return epics_subs
97+
98+
99+
def find_bobfiles_in_search_path(bobfile_search_path: list[Path]) -> dict[str, Path]:
100+
written_bobfiles: dict[str, Path] = {}
101+
102+
for bobfile_dir in bobfile_search_path:
103+
for dirpath, _, filenames in os.walk(bobfile_dir):
104+
for filename in filenames:
105+
if filename.endswith((".bob", ".opi")):
106+
full_path = Path(os.path.join(dirpath, filename))
107+
logger.info(f"Found additional bob/opi file: {full_path}")
108+
written_bobfiles[filename] = full_path
109+
return written_bobfiles
110+
111+
112+
def pack_close_to_square(
113+
rectangle_sizes: list[tuple[int, int]], max_height: int, padding: int
114+
) -> list[tuple[int, int]]:
115+
"""Pack rectangles into as close to a square as possible.
116+
117+
Args:
118+
rectangle_sizes (list[tuple[int, int]]): List of (width, height) for each rect
119+
max_height (int): Maximum allowed height for the packed rectangles.
120+
121+
Returns:
122+
tuple[int, int]: (total_width, total_height) of the packed rectangles.
123+
"""
124+
125+
# Add padding to each rectangle size
126+
padded_rectangle_sizes = [
127+
(width + padding, height + padding) for width, height in rectangle_sizes
128+
]
129+
130+
height = 100
131+
width = 100
132+
increment = 100
133+
134+
iteration = 0
135+
while True:
136+
try:
137+
if height < width and height + increment <= max_height:
138+
height += increment
139+
else:
140+
width += increment
141+
packed_x_y_positions = rpack.pack(padded_rectangle_sizes, width, height)
142+
break
143+
except rpack.PackingImpossibleError as err:
144+
iteration += 1
145+
if iteration > 1000:
146+
raise RuntimeError("Unable to pack rects within max height.") from err
147+
else:
148+
logger.warning(
149+
f"Packing impossible at dims {width}x{height}, increasing size."
150+
)
151+
152+
return packed_x_y_positions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
file db_with_readbacks.db
2+
{
3+
pattern {}
4+
{}
5+
}
6+
7+
file simple_db.db
8+
{
9+
pattern {}
10+
{}
11+
{}
12+
{}
13+
}

0 commit comments

Comments
 (0)