Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions example/t01-services/synoptic/JsonMap.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
"file": "dcam1.bob",
"children": [
{
"file": "ProfileCursorGraphs.bob",
"file": "techui-support/bob/ADAravis/ProfileCursorGraphs.bob",
"macros": {
"P": "BL01T-DI-CAM-01",
"R": ":CAM:",
"label": "CAM",
"IOC": "https://t01-opis.diamond.ac.uk/bl01t-di-cam-01"
"IOC": "../../../../bl01t-di-cam-01"
},
"displayName": "CAM"
},
Expand Down Expand Up @@ -72,7 +72,7 @@
"M": ":X",
"P": "BL01T-MO-MOTOR-01",
"label": "X1",
"IOC": "https://t01-opis.diamond.ac.uk/bl01t-mo-motor-01"
"IOC": "../../../../bl01t-mo-motor-01"
},
"displayName": "X1"
},
Expand All @@ -82,7 +82,7 @@
"M": ":A",
"P": "BL01T-MO-MOTOR-01",
"label": "A",
"IOC": "https://t01-opis.diamond.ac.uk/bl01t-mo-motor-01"
"IOC": "../../../../bl01t-mo-motor-01"
},
"displayName": "A"
},
Expand Down
2 changes: 1 addition & 1 deletion example/t01-services/synoptic/dcam1.bob
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<P>BL01T-DI-CAM-01</P>
<R>:CAM:</R>
<label>CAM</label>
<IOC>https://t01-opis.diamond.ac.uk/bl01t-di-cam-01</IOC>
<IOC>../../../../bl01t-di-cam-01</IOC>
</macros>
<x>0</x>
<y>0</y>
Expand Down
8 changes: 4 additions & 4 deletions example/t01-services/synoptic/motor1.bob
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
<height>120</height>
<file>techui-support/bob/pmac/motor_embed.bob</file>
<macros>
<P>BL01T-MO-MOTOR-01</P>
<M>:X</M>
<P>BL01T-MO-MOTOR-01</P>
<label>X1</label>
<IOC>https://t01-opis.diamond.ac.uk/bl01t-mo-motor-01</IOC>
<IOC>../../../../bl01t-mo-motor-01</IOC>
</macros>
<x>0</x>
<y>0</y>
Expand All @@ -27,10 +27,10 @@
<height>120</height>
<file>techui-support/bob/pmac/motor_embed.bob</file>
<macros>
<P>BL01T-MO-MOTOR-01</P>
<M>:A</M>
<P>BL01T-MO-MOTOR-01</P>
<label>A</label>
<IOC>https://t01-opis.diamond.ac.uk/bl01t-mo-motor-01</IOC>
<IOC>../../../../bl01t-mo-motor-01</IOC>
</macros>
<x>0</x>
<y>150</y>
Expand Down
16 changes: 14 additions & 2 deletions src/techui_builder/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,12 @@ def _update_macros(self, component: Entity) -> tuple[str, dict[str, str]]:

return component_name, new_macros

def resolve_service_dir(self, service_name: str, screen_dir: Path) -> str:
"""A service's screen directory, relative to the screen linking to it."""
# Service directories sit alongside the synoptic directory, not inside it
service_dir = self.synoptic_dir.resolve().parent / service_name
return str(service_dir.relative_to(screen_dir.resolve(), walk_up=True))

def _allocate_widget(
self, screen_mapping: SupportEntityScreen, component: Entity
) -> EmbeddedDisplay | ActionButton | None | list[EmbeddedDisplay | ActionButton]:
Expand All @@ -193,7 +199,8 @@ def _allocate_widget(
file = Template(screen_mapping.file).render(component.macros)
if file.startswith("$(IOC)"):
screen_path = support_screen_path = file.replace(
"$(IOC)", f"{self.beamline_url}/{component.service_name}"
"$(IOC)",
self.resolve_service_dir(component.service_name, self.synoptic_dir),
) # Only works with related displays as
# embedded displays need to access the file to get dimensions

Expand Down Expand Up @@ -252,7 +259,12 @@ def _allocate_widget(

# TODO: Change this to pvi_button
if True:
new_widget.macro("IOC", f"{self.beamline_url}/{component.service_name}")
new_widget.macro(
"IOC",
self.resolve_service_dir(
component.service_name, Path(screen_path).parent
),
)

# The only other option is for related displays
else:
Expand Down
6 changes: 2 additions & 4 deletions src/techui_builder/generate_jsonmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,17 +72,15 @@ def __post_init__(self):
def generate_json_map(
self,
screen_path: Path,
dest_path: Path,
current_component_name: str | None = None,
name_elem: str | None = None,
) -> ScreenNode:
"""Recursively generate JSON map from .bob file tree"""
ctx = CrawlContext(
components=self.techui_yaml.components,
synoptic_dir=self._parent_path,
link_base_dir=dest_path,
component_name=current_component_name,
service_name="",
screen=screen_path,
)
return crawl(screen_path, ctx, link_name=name_elem)

Expand All @@ -95,7 +93,7 @@ def write_json_map(
f"Cannot generate json map for {self.bob_path}. Has it been generated?"
)

json_map = self.generate_json_map(self.bob_path, self._parent_path)
json_map = self.generate_json_map(self.bob_path)
with open(self._write_directory / "JsonMap.json", "w") as f:
f.write(
json.dumps(json_map, indent=4, default=lambda o: serialise_node(o))
Expand Down
99 changes: 52 additions & 47 deletions src/techui_builder/jsonmap/crawl.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
"""Recursively crawl a tree of .bob screens into a tree of ScreenNodes."""

import logging
from collections.abc import Mapping
from dataclasses import dataclass, replace
from dataclasses import dataclass, field, replace
from pathlib import Path

from lxml import etree, objectify
from lxml.objectify import ObjectifiedElement

from techui_builder.jsonmap.links import (
WidgetLink,
WidgetType,
assumed_exists,
extract_links,
find_local_screen,
resolve_link,
substitute_macros,
)
from techui_builder.jsonmap.naming import (
find_techui_label,
Expand All @@ -21,6 +20,9 @@
)
from techui_builder.jsonmap.nodes import ScreenNode
from techui_builder.models import Component
from techui_builder.utils import WidgetType

logger_ = logging.getLogger(__name__)


@dataclass
Expand All @@ -29,23 +31,30 @@ class CrawlContext:

components: Mapping[str, Component]
synoptic_dir: Path # ScreenNode.file is relative to this
link_base_dir: Path # link files are resolved against this; never changes
component_name: str | None
service_name: str

def with_screen_component(self, screen_path: Path) -> "CrawlContext":
"""A copy for the screen's component, if it is one and none is set yet."""
if self.component_name is not None or screen_path.stem not in self.components:
return self

component_name = screen_path.stem
# We know from the if statement that it exists
component = self.components.get(component_name)
assert isinstance(component, Component)
# TODO: How to find the screens if PV prefix is not the service name???
return replace(
self, component_name=component_name, service_name=component.prefix.lower()
)
screen: Path # the screen being crawled
macros: dict[str, str] = field(default_factory=dict) # including inherited ones

def with_screen(self, screen: Path) -> "CrawlContext":
"""A copy for crawling a screen's links, in its component if it names one."""
component_name = self.component_name
if component_name is None and screen.stem in self.components:
component_name = screen.stem

return replace(self, screen=screen, component_name=component_name)


def format_screen(screen: Path, synoptic_dir: Path) -> str:
"""The screen's path, relative to the synoptic directory."""
return str(screen.resolve().relative_to(synoptic_dir.resolve(), walk_up=True))


def inherit_macros(
parent_macros: Mapping[str, str], macros: Mapping[str, str]
) -> dict[str, str]:
"""The parent's macros overridden by a link's macros, expanded like Phoebus."""
expanded = {k: substitute_macros(v, parent_macros) for k, v in macros.items()}
return {**parent_macros, **expanded}


def crawl_link(
Expand All @@ -57,42 +66,43 @@ def crawl_link(

If it can't be found, a leaf ScreenNode is returned.
"""
local_path = find_local_screen(link.file, ctx.link_base_dir, ctx.service_name)
macros = inherit_macros(ctx.macros, link.macros)
screen = resolve_link(link.file, macros, ctx.screen)

if not screen.is_file():
logger_.debug(f"Link {link.file} -> {screen}: not found")
return ScreenNode(
format_screen(screen, ctx.synoptic_dir),
display_name,
exists=False,
macros=macros,
)

logger_.debug(f"Link {link.file} -> {screen}: found")

# Crawl the next file
if local_path is not None:
# TODO: investigate non-recursive approaches?
return crawl(local_path, ctx, link_name=link.name)

return ScreenNode(
link.file,
display_name,
exists=assumed_exists(link.file, link.macros),
)
# TODO: investigate non-recursive approaches?
node = crawl(screen, replace(ctx, macros=macros), link_name=link.name)
node.macros = macros
return node


def crawl(
screen_path: Path, ctx: CrawlContext, link_name: str | None = None
) -> ScreenNode:
def crawl(screen: Path, ctx: CrawlContext, link_name: str | None = None) -> ScreenNode:
"""Crawl a .bob screen and the screens it links to into a ScreenNode."""

# Create initial node at top of .bob file
current_node = ScreenNode(
str(
screen_path.resolve().relative_to(ctx.synoptic_dir.resolve(), walk_up=True)
),
display_name=None,
format_screen(screen, ctx.synoptic_dir), display_name=None
)

ctx = ctx.with_screen_component(screen_path)
ctx = ctx.with_screen(screen)

try:
# Create xml tree from .bob file
tree = objectify.parse(screen_path.absolute())
root: ObjectifiedElement = tree.getroot()
root = objectify.parse(screen.absolute()).getroot()

# Label for the linking widget, else the screen's own <name>, else file stem
own_name = name_or_file_stem(root.name.text, screen_path)
own_name = name_or_file_stem(root.name.text, screen)
label = find_techui_label(ctx.components, ctx.component_name, link_name)
current_node.display_name = label if label is not None else own_name

Expand All @@ -107,15 +117,10 @@ def crawl(

if link.type == WidgetType.EMBEDDED:
for embedded_child in child_node.children:
embedded_child.macros = {**embedded_child.macros, **link.macros}
embedded_child.display_name = display_name
embedded_child.exists = "IOC" in link.macros or (
"https://" in str(embedded_child.file)
)
current_node.children.append(embedded_child)

else:
child_node.macros = link.macros
# TODO: make this work for only list[ScreenNode]
assert isinstance(current_node.children, list)
# TODO: fix typing
Expand Down
42 changes: 19 additions & 23 deletions src/techui_builder/jsonmap/links.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Links from a .bob screen to other screens."""

import logging
import re
from collections.abc import Iterator, Mapping
from dataclasses import dataclass
Expand All @@ -14,7 +15,9 @@
_get_nav_tabs,
)

PVI_FILE_RE = re.compile(r"^(?:\$\(IOC\))\/([a-zA-Z]+[.a-zA-Z]+)$")
logger_ = logging.getLogger(__name__)

MACRO_RE = re.compile(r"\$(?:\((\w+)\)|\{(\w+)\})")


@dataclass
Expand All @@ -29,13 +32,13 @@ class WidgetLink:

def extract_file_text(file_elem: ObjectifiedElement) -> str:
"""The stripped text of a <file> element."""
# Keep raw string to preserve urls
# Keep the raw string; macros are expanded later
return file_elem.text.strip() if file_elem.text else ""


def is_bob(file: str) -> bool:
"""Whether the file is a .bob screen."""
return Path(file).suffix == ".bob"
"""Whether the link is to a .bob screen"""
return file.endswith(".bob")


def extract_links(root: ObjectifiedElement) -> Iterator[WidgetLink]:
Expand Down Expand Up @@ -82,6 +85,7 @@ def extract_links(root: ObjectifiedElement) -> Iterator[WidgetLink]:
file = extract_file_text(file_elem)
# Skip links that are not .bob screens
if not is_bob(file):
logger_.debug(f"Skipping link to {file}: not a .bob screen")
continue

yield WidgetLink(file, name, widget_type, macros)
Expand All @@ -95,29 +99,21 @@ def extract_links(root: ObjectifiedElement) -> Iterator[WidgetLink]:
file = extract_file_text(file_elem)
# Skip links that are not .bob screens
if not is_bob(file):
logger_.debug(f"Skipping link to {file}: not a .bob screen")
continue

yield WidgetLink(file, name, widget_type, macros)


def resolve_link_path(file: str, base_dir: Path, service_name: str) -> Path:
"""Resolve a link's file to a local path."""

match = PVI_FILE_RE.fullmatch(file)
# The file path is a PVI screen, so attempt to find that screen
if match:
file_name = match.group(1)
return base_dir / f"../{service_name}/{file_name}"

return base_dir / file


def find_local_screen(file: str, base_dir: Path, service_name: str) -> Path | None:
"""Resolve a link's file, returning the path if it can be crawled locally."""
path = resolve_link_path(file, base_dir, service_name)
return path if path.is_file() else None
def substitute_macros(text: str, macros: Mapping[str, str]) -> str:
"""Replace $(NAME) and ${NAME} with macro values, leaving unknown macros as-is."""
return MACRO_RE.sub(
lambda m: macros.get(m.group(1) or m.group(2), m.group(0)), text
)


def assumed_exists(file: str, macros: Mapping[str, str]) -> bool:
"""Whether a link's file that could not be found locally is assumed to exist."""
return "IOC" in macros or ("https:/" in file)
def resolve_link(file: str, macros: Mapping[str, str], screen: Path) -> Path:
"""Resolve a link's file to a local path, relative to the linking screen."""
file = substitute_macros(file, macros)
# Phoebus resolves relative files against the display containing the link
return screen.parent / file
1 change: 0 additions & 1 deletion src/techui_builder/jsonmap/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ class ScreenNode:
file: str
display_name: str | None
exists: bool = True
duplicate: bool = False
children: list["ScreenNode"] = field(default_factory=list)
macros: dict[str, str] = field(default_factory=dict)
error: str = ""
Expand Down
Loading
Loading