beehave-bt 0.1.0__py3-none-any.whl
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.
- beehave/__init__.py +80 -0
- beehave/__main__.py +9 -0
- beehave/api.py +105 -0
- beehave/cli.py +271 -0
- beehave/config.py +140 -0
- beehave/diagnostics.py +41 -0
- beehave/errors.py +37 -0
- beehave/flatten.py +311 -0
- beehave/graph.py +187 -0
- beehave/ids.py +212 -0
- beehave/model.py +177 -0
- beehave/parsers/__init__.py +67 -0
- beehave/parsers/_btcpp.py +611 -0
- beehave/parsers/_xml.py +67 -0
- beehave/parsers/btcpp_v3.py +128 -0
- beehave/parsers/btcpp_v4.py +157 -0
- beehave/parsers/detect.py +81 -0
- beehave/parsers/pytrees.py +476 -0
- beehave/parsers/ros.py +84 -0
- beehave/py.typed +0 -0
- beehave/render/__init__.py +21 -0
- beehave/render/_common.py +37 -0
- beehave/render/dot.py +41 -0
- beehave/render/json.py +92 -0
- beehave/render/mermaid.py +72 -0
- beehave/render/text.py +28 -0
- beehave/render/theme.py +76 -0
- beehave/stats.py +93 -0
- beehave/walk.py +744 -0
- beehave_bt-0.1.0.dist-info/METADATA +368 -0
- beehave_bt-0.1.0.dist-info/RECORD +35 -0
- beehave_bt-0.1.0.dist-info/WHEEL +4 -0
- beehave_bt-0.1.0.dist-info/entry_points.txt +3 -0
- beehave_bt-0.1.0.dist-info/licenses/LICENSE +202 -0
- beehave_bt-0.1.0.dist-info/licenses/NOTICE +8 -0
beehave/__init__.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Beehave: the small branch that matters.
|
|
2
|
+
|
|
3
|
+
Beehave reads a behavior tree and gives back only the actions the robot can
|
|
4
|
+
take, in order, with the conditions that lead to each one.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> import beehave
|
|
8
|
+
>>> result = beehave.analyze("mission.xml") # doctest: +SKIP
|
|
9
|
+
>>> print(beehave.to_text(result.graph)) # doctest: +SKIP
|
|
10
|
+
START --[not IsBatteryCritical]--> Arm
|
|
11
|
+
...
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
17
|
+
|
|
18
|
+
from beehave.api import Analysis, analyze, analyze_py_trees
|
|
19
|
+
from beehave.config import Config, load_config
|
|
20
|
+
from beehave.errors import (
|
|
21
|
+
BeehaveError,
|
|
22
|
+
ConfigError,
|
|
23
|
+
FlattenError,
|
|
24
|
+
ParseError,
|
|
25
|
+
TreeSelectionError,
|
|
26
|
+
UnsupportedFormatError,
|
|
27
|
+
)
|
|
28
|
+
from beehave.flatten import FlatTree, flatten
|
|
29
|
+
from beehave.graph import ActionGraph, Condition, Edge, Literal, Outcome, Vertex, VertexKind
|
|
30
|
+
from beehave.ids import IdScheme, NodeRef, RuntimeNode, node_refs, runtime_ids
|
|
31
|
+
from beehave.parsers import parse_file, parse_string
|
|
32
|
+
from beehave.parsers.pytrees import from_py_trees, load_py_trees
|
|
33
|
+
from beehave.render import to_dot, to_json, to_mermaid, to_text
|
|
34
|
+
from beehave.stats import Stats, compute_stats
|
|
35
|
+
from beehave.walk import build_graph
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
__version__ = version("beehave-bt")
|
|
39
|
+
except PackageNotFoundError: # pragma: no cover - running from a source tree
|
|
40
|
+
__version__ = "0.0.0+unknown"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"ActionGraph",
|
|
44
|
+
"Analysis",
|
|
45
|
+
"BeehaveError",
|
|
46
|
+
"Condition",
|
|
47
|
+
"Config",
|
|
48
|
+
"ConfigError",
|
|
49
|
+
"Edge",
|
|
50
|
+
"FlatTree",
|
|
51
|
+
"FlattenError",
|
|
52
|
+
"IdScheme",
|
|
53
|
+
"Literal",
|
|
54
|
+
"NodeRef",
|
|
55
|
+
"Outcome",
|
|
56
|
+
"ParseError",
|
|
57
|
+
"RuntimeNode",
|
|
58
|
+
"Stats",
|
|
59
|
+
"TreeSelectionError",
|
|
60
|
+
"UnsupportedFormatError",
|
|
61
|
+
"Vertex",
|
|
62
|
+
"VertexKind",
|
|
63
|
+
"__version__",
|
|
64
|
+
"analyze",
|
|
65
|
+
"analyze_py_trees",
|
|
66
|
+
"build_graph",
|
|
67
|
+
"compute_stats",
|
|
68
|
+
"flatten",
|
|
69
|
+
"from_py_trees",
|
|
70
|
+
"load_config",
|
|
71
|
+
"load_py_trees",
|
|
72
|
+
"node_refs",
|
|
73
|
+
"parse_file",
|
|
74
|
+
"parse_string",
|
|
75
|
+
"runtime_ids",
|
|
76
|
+
"to_dot",
|
|
77
|
+
"to_json",
|
|
78
|
+
"to_mermaid",
|
|
79
|
+
"to_text",
|
|
80
|
+
]
|
beehave/__main__.py
ADDED
beehave/api.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""One call to run the whole pipeline: read, flatten, walk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from beehave.flatten import FlatTree, flatten
|
|
10
|
+
from beehave.parsers import parse_file
|
|
11
|
+
from beehave.parsers.pytrees import from_py_trees
|
|
12
|
+
from beehave.stats import Stats, compute_stats
|
|
13
|
+
from beehave.walk import DEFAULT_MAX_PATHS, build_graph
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from beehave.config import Config
|
|
19
|
+
from beehave.graph import ActionGraph
|
|
20
|
+
from beehave.model import Document
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Analysis:
|
|
25
|
+
"""Everything Beehave computed for one tree.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
document: All trees loaded from the input files.
|
|
29
|
+
tree: The selected tree, with subtrees inlined.
|
|
30
|
+
graph: Its action graph. Its diagnostics include the ones found while loading.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
document: Document
|
|
34
|
+
tree: FlatTree
|
|
35
|
+
graph: ActionGraph
|
|
36
|
+
|
|
37
|
+
@cached_property
|
|
38
|
+
def stats(self) -> Stats:
|
|
39
|
+
"""The pilot metrics for this tree."""
|
|
40
|
+
return compute_stats(self.tree, self.graph)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def analyze(
|
|
44
|
+
*paths: str | Path,
|
|
45
|
+
tree: str | None = None,
|
|
46
|
+
config: Config | None = None,
|
|
47
|
+
max_paths: int = DEFAULT_MAX_PATHS,
|
|
48
|
+
) -> Analysis:
|
|
49
|
+
"""Load tree files and build the action graph of one tree.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
*paths: BT.CPP XML files. Trees from all of them are merged.
|
|
53
|
+
tree: The tree to analyse; defaults to the main tree.
|
|
54
|
+
config: Project settings (which custom nodes are conditions).
|
|
55
|
+
max_paths: Cap on guarded transitions per action outcome.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
The document, the flattened tree and its action graph.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
beehave.BeehaveError: If the input cannot be read or analysed.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
>>> import beehave
|
|
65
|
+
>>> result = beehave.analyze("mission.xml") # doctest: +SKIP
|
|
66
|
+
>>> print(beehave.to_mermaid(result.graph)) # doctest: +SKIP
|
|
67
|
+
"""
|
|
68
|
+
return _analyze(parse_file(*paths, config=config), tree, max_paths)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def analyze_py_trees(
|
|
72
|
+
tree: object,
|
|
73
|
+
*,
|
|
74
|
+
config: Config | None = None,
|
|
75
|
+
max_paths: int = DEFAULT_MAX_PATHS,
|
|
76
|
+
) -> Analysis:
|
|
77
|
+
"""Build the action graph of a live py_trees tree.
|
|
78
|
+
|
|
79
|
+
The tree is only read: no behaviour is set up or ticked. Use
|
|
80
|
+
:func:`beehave.load_py_trees` to import a tree from a module first.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
tree: The root ``Behaviour``, or a ``BehaviourTree``.
|
|
84
|
+
config: Project settings (which behaviour classes are conditions).
|
|
85
|
+
max_paths: Cap on guarded transitions per action outcome.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The document, the tree and its action graph.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
beehave.BeehaveError: If the tree cannot be read, or py_trees is missing.
|
|
92
|
+
|
|
93
|
+
Example:
|
|
94
|
+
>>> import beehave
|
|
95
|
+
>>> result = beehave.analyze_py_trees(create_root()) # doctest: +SKIP
|
|
96
|
+
>>> print(beehave.to_mermaid(result.graph)) # doctest: +SKIP
|
|
97
|
+
"""
|
|
98
|
+
return _analyze(from_py_trees(tree, config=config), None, max_paths)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _analyze(document: Document, tree: str | None, max_paths: int) -> Analysis:
|
|
102
|
+
flat = flatten(document, tree)
|
|
103
|
+
graph = build_graph(flat, max_paths=max_paths)
|
|
104
|
+
graph = replace(graph, diagnostics=document.diagnostics + graph.diagnostics)
|
|
105
|
+
return Analysis(document, flat, graph)
|
beehave/cli.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""The ``beehave`` command line.
|
|
2
|
+
|
|
3
|
+
Examples::
|
|
4
|
+
|
|
5
|
+
beehave graph mission.xml # transitions as text
|
|
6
|
+
beehave graph mission.xml -f mermaid -o mission.mmd
|
|
7
|
+
beehave stats mission.xml # size, reduction, breakage
|
|
8
|
+
beehave trees mission.xml # list the trees in the files
|
|
9
|
+
beehave ids mission.xml # runtime ids and paths of every node
|
|
10
|
+
beehave graph --py-trees robot.trees:create_root # a py_trees tree (runs that code)
|
|
11
|
+
beehave graph behavior_trees/ -t NavigateToPose # every tree in a folder
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from dataclasses import replace
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
from beehave import __version__
|
|
24
|
+
from beehave.api import analyze, analyze_py_trees
|
|
25
|
+
from beehave.config import Config, find_config, load_config
|
|
26
|
+
from beehave.errors import BeehaveError
|
|
27
|
+
from beehave.ids import IdScheme, runtime_ids
|
|
28
|
+
from beehave.parsers import parse_file
|
|
29
|
+
from beehave.parsers.pytrees import from_py_trees, load_py_trees
|
|
30
|
+
from beehave.render import FORMATS
|
|
31
|
+
from beehave.render.json import node_ref
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from collections.abc import Sequence
|
|
35
|
+
|
|
36
|
+
from beehave.api import Analysis
|
|
37
|
+
from beehave.diagnostics import Diagnostic
|
|
38
|
+
from beehave.model import Document
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
42
|
+
"""Run the command line and return the exit code.
|
|
43
|
+
|
|
44
|
+
Exit codes: 0 on success, 1 when the input cannot be analysed, 2 on a usage error.
|
|
45
|
+
"""
|
|
46
|
+
args = _parser().parse_args(argv)
|
|
47
|
+
_check_inputs(args)
|
|
48
|
+
try:
|
|
49
|
+
return int(args.run(args))
|
|
50
|
+
except BeehaveError as exc:
|
|
51
|
+
print(f"beehave: error: {exc}", file=sys.stderr)
|
|
52
|
+
return 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parser() -> argparse.ArgumentParser:
|
|
56
|
+
parser = argparse.ArgumentParser(
|
|
57
|
+
prog="beehave",
|
|
58
|
+
description="Show only the actions a behavior tree can take, and what runs next.",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument("--version", action="version", version=f"beehave {__version__}")
|
|
61
|
+
commands = parser.add_subparsers(title="commands", required=True, metavar="COMMAND")
|
|
62
|
+
|
|
63
|
+
graph = commands.add_parser("graph", help="build the action graph of a tree")
|
|
64
|
+
_common(graph)
|
|
65
|
+
graph.set_defaults(parser=graph)
|
|
66
|
+
graph.add_argument(
|
|
67
|
+
"-f",
|
|
68
|
+
"--format",
|
|
69
|
+
choices=sorted(FORMATS),
|
|
70
|
+
default="text",
|
|
71
|
+
help="output format (default: text)",
|
|
72
|
+
)
|
|
73
|
+
graph.add_argument("-o", "--output", type=Path, help="write to a file instead of stdout")
|
|
74
|
+
graph.set_defaults(run=_graph)
|
|
75
|
+
|
|
76
|
+
stats = commands.add_parser("stats", help="print size, reduction and breakage metrics")
|
|
77
|
+
_common(stats)
|
|
78
|
+
stats.set_defaults(parser=stats)
|
|
79
|
+
stats.add_argument("--json", action="store_true", help="print the metrics as JSON")
|
|
80
|
+
stats.set_defaults(run=_stats)
|
|
81
|
+
|
|
82
|
+
ids = commands.add_parser(
|
|
83
|
+
"ids", help="list every node with its runtime id, path and action vertex"
|
|
84
|
+
)
|
|
85
|
+
_common(ids)
|
|
86
|
+
ids.set_defaults(parser=ids)
|
|
87
|
+
ids.add_argument(
|
|
88
|
+
"--scheme",
|
|
89
|
+
choices=[scheme.value for scheme in IdScheme],
|
|
90
|
+
default=IdScheme.BTCPP_V4.value,
|
|
91
|
+
help="btcpp-v4: from 1, like BT.CPP v4 UIDs (default); preorder: from 0",
|
|
92
|
+
)
|
|
93
|
+
ids.add_argument("--json", action="store_true", help="print the nodes as JSON")
|
|
94
|
+
ids.set_defaults(run=_ids)
|
|
95
|
+
|
|
96
|
+
trees = commands.add_parser("trees", help="list the trees defined in the files")
|
|
97
|
+
_inputs(trees)
|
|
98
|
+
trees.set_defaults(run=_trees, parser=trees)
|
|
99
|
+
return parser
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _inputs(parser: argparse.ArgumentParser) -> None:
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"files",
|
|
105
|
+
nargs="*",
|
|
106
|
+
type=Path,
|
|
107
|
+
metavar="FILE",
|
|
108
|
+
help="BT.CPP XML files, or folders: every .xml file directly in a folder is read",
|
|
109
|
+
)
|
|
110
|
+
parser.add_argument(
|
|
111
|
+
"--py-trees",
|
|
112
|
+
metavar="SPEC",
|
|
113
|
+
help="read a py_trees tree instead: module:function or file.py:function. "
|
|
114
|
+
"This imports the module and calls the function, so it runs that code",
|
|
115
|
+
)
|
|
116
|
+
parser.add_argument(
|
|
117
|
+
"--kwargs", metavar="JSON", help="arguments for the --py-trees function, as a JSON object"
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"-c", "--config", type=Path, help="project config (default: ./beehave.yaml)"
|
|
121
|
+
)
|
|
122
|
+
parser.add_argument(
|
|
123
|
+
"--package",
|
|
124
|
+
action="append",
|
|
125
|
+
default=[],
|
|
126
|
+
metavar="NAME=DIR",
|
|
127
|
+
help='where <include ros_pkg="NAME"> finds the package; repeat for several',
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _common(parser: argparse.ArgumentParser) -> None:
|
|
132
|
+
_inputs(parser)
|
|
133
|
+
parser.add_argument("-t", "--tree", help="tree to analyse (default: the main tree)")
|
|
134
|
+
parser.add_argument("-q", "--quiet", action="store_true", help="do not print warnings")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _check_inputs(args: argparse.Namespace) -> None:
|
|
138
|
+
error = args.parser.error
|
|
139
|
+
for value in args.package:
|
|
140
|
+
name, _, directory = value.partition("=")
|
|
141
|
+
if not name or not directory:
|
|
142
|
+
error("--package takes NAME=DIR, for example --package my_robot_bt=src/my_robot_bt")
|
|
143
|
+
if args.files and args.py_trees:
|
|
144
|
+
error("give tree files or --py-trees, not both")
|
|
145
|
+
if not args.files and not args.py_trees:
|
|
146
|
+
error("give tree files or --py-trees")
|
|
147
|
+
if args.py_trees and getattr(args, "tree", None):
|
|
148
|
+
error("--tree does not apply to --py-trees")
|
|
149
|
+
if args.kwargs is None:
|
|
150
|
+
return
|
|
151
|
+
if not args.py_trees:
|
|
152
|
+
error("--kwargs needs --py-trees")
|
|
153
|
+
try:
|
|
154
|
+
kwargs = json.loads(args.kwargs)
|
|
155
|
+
except json.JSONDecodeError:
|
|
156
|
+
kwargs = None
|
|
157
|
+
if not isinstance(kwargs, dict):
|
|
158
|
+
error("--kwargs must be a JSON object")
|
|
159
|
+
args.kwargs = kwargs
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _config(args: argparse.Namespace) -> Config:
|
|
163
|
+
path = args.config or find_config()
|
|
164
|
+
config = load_config(path) if path else Config()
|
|
165
|
+
if not args.package:
|
|
166
|
+
return config
|
|
167
|
+
packages = dict(config.packages)
|
|
168
|
+
for value in args.package:
|
|
169
|
+
name, _, directory = value.partition("=")
|
|
170
|
+
packages[name] = Path(directory)
|
|
171
|
+
return replace(config, packages=packages)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _analysis(args: argparse.Namespace) -> Analysis:
|
|
175
|
+
if args.py_trees:
|
|
176
|
+
tree = load_py_trees(args.py_trees, **(args.kwargs or {}))
|
|
177
|
+
return analyze_py_trees(tree, config=_config(args))
|
|
178
|
+
return analyze(*args.files, tree=args.tree, config=_config(args))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _document(args: argparse.Namespace) -> Document:
|
|
182
|
+
if args.py_trees:
|
|
183
|
+
tree = load_py_trees(args.py_trees, **(args.kwargs or {}))
|
|
184
|
+
return from_py_trees(tree, config=_config(args))
|
|
185
|
+
return parse_file(*args.files, config=_config(args))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _graph(args: argparse.Namespace) -> int:
|
|
189
|
+
result = _analysis(args)
|
|
190
|
+
_warn(result.graph.diagnostics, quiet=args.quiet)
|
|
191
|
+
output = FORMATS[args.format](result.graph)
|
|
192
|
+
if args.output:
|
|
193
|
+
args.output.write_text(output, encoding="utf-8")
|
|
194
|
+
else:
|
|
195
|
+
sys.stdout.write(output)
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _stats(args: argparse.Namespace) -> int:
|
|
200
|
+
result = _analysis(args)
|
|
201
|
+
_warn(result.graph.diagnostics, quiet=args.quiet)
|
|
202
|
+
if args.json:
|
|
203
|
+
sys.stdout.write(json.dumps(result.stats.to_dict(), indent=2) + "\n")
|
|
204
|
+
else:
|
|
205
|
+
sys.stdout.write(result.stats.to_text())
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _ids(args: argparse.Namespace) -> int:
|
|
210
|
+
result = _analysis(args)
|
|
211
|
+
_warn(result.graph.diagnostics, quiet=args.quiet)
|
|
212
|
+
scheme = IdScheme(args.scheme)
|
|
213
|
+
nodes = runtime_ids(result.tree, scheme)
|
|
214
|
+
vertices = {v.node.path: v.id for v in result.graph.actions if v.node}
|
|
215
|
+
if args.json:
|
|
216
|
+
data = {
|
|
217
|
+
"schema": "beehave.runtime-ids",
|
|
218
|
+
"version": 1,
|
|
219
|
+
"tree": result.tree.id,
|
|
220
|
+
"source_format": result.tree.format,
|
|
221
|
+
"scheme": scheme.value,
|
|
222
|
+
"nodes": [
|
|
223
|
+
{
|
|
224
|
+
"id": node.id,
|
|
225
|
+
"parent": node.parent,
|
|
226
|
+
"kind": node.kind.value,
|
|
227
|
+
"type": node.type,
|
|
228
|
+
"name": node.name,
|
|
229
|
+
"subtree": node.subtree,
|
|
230
|
+
"vertex": vertices.get(node.ref.path),
|
|
231
|
+
**(node_ref(node.ref) or {}),
|
|
232
|
+
"location": None
|
|
233
|
+
if node.location is None
|
|
234
|
+
else {"file": node.location.file, "line": node.location.line},
|
|
235
|
+
}
|
|
236
|
+
for node in nodes.values()
|
|
237
|
+
],
|
|
238
|
+
}
|
|
239
|
+
sys.stdout.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
240
|
+
return 0
|
|
241
|
+
rows = [("ID", "KIND", "VERTEX", "RUNTIME PATH", "PATH")]
|
|
242
|
+
rows += [
|
|
243
|
+
(
|
|
244
|
+
str(node.id),
|
|
245
|
+
node.kind.value,
|
|
246
|
+
vertices.get(node.ref.path, "-"),
|
|
247
|
+
node.ref.runtime_path or "-",
|
|
248
|
+
node.ref.path,
|
|
249
|
+
)
|
|
250
|
+
for node in nodes.values()
|
|
251
|
+
]
|
|
252
|
+
widths = [max(len(row[i]) for row in rows) for i in range(4)]
|
|
253
|
+
for row in rows:
|
|
254
|
+
cells = [cell.ljust(width) for cell, width in zip(row, widths, strict=False)]
|
|
255
|
+
sys.stdout.write(" ".join([*cells, row[4]]) + "\n")
|
|
256
|
+
return 0
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _trees(args: argparse.Namespace) -> int:
|
|
260
|
+
document = _document(args)
|
|
261
|
+
for tree_id in document.trees:
|
|
262
|
+
suffix = " (main)" if tree_id == document.main_tree else ""
|
|
263
|
+
sys.stdout.write(f"{tree_id}{suffix}\n")
|
|
264
|
+
return 0
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _warn(diagnostics: Sequence[Diagnostic], *, quiet: bool) -> None:
|
|
268
|
+
if quiet:
|
|
269
|
+
return
|
|
270
|
+
for diagnostic in diagnostics:
|
|
271
|
+
print(f"beehave: {diagnostic}", file=sys.stderr)
|
beehave/config.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Per-project configuration.
|
|
2
|
+
|
|
3
|
+
Beehave works on any tree without configuration. A project only needs a config
|
|
4
|
+
file to say which of its custom nodes are conditions "in disguise" (for example
|
|
5
|
+
an action that only checks something), or to force a node to be an action::
|
|
6
|
+
|
|
7
|
+
# beehave.yaml
|
|
8
|
+
conditions:
|
|
9
|
+
- BatteryOK
|
|
10
|
+
- IsEmergency
|
|
11
|
+
actions:
|
|
12
|
+
- Probe
|
|
13
|
+
packages: # where <include ros_pkg="..."> finds a package
|
|
14
|
+
my_robot_bt: src/my_robot_bt
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
from beehave.errors import ConfigError
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Mapping
|
|
29
|
+
|
|
30
|
+
DEFAULT_CONFIG_NAMES = ("beehave.yaml", ".beehave.yaml")
|
|
31
|
+
_KNOWN_KEYS = frozenset({"conditions", "actions", "packages"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Config:
|
|
36
|
+
"""Project settings that change how nodes are classified.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
conditions: Node types to treat as conditions, whatever the XML says.
|
|
40
|
+
actions: Node types to treat as actions, whatever the XML says.
|
|
41
|
+
packages: ROS package directories, by package name, for
|
|
42
|
+
``<include ros_pkg="...">``. They win over a sourced workspace.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
conditions: frozenset[str] = frozenset()
|
|
46
|
+
actions: frozenset[str] = frozenset()
|
|
47
|
+
packages: Mapping[str, Path] = field(default_factory=dict, hash=False)
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
both = self.conditions & self.actions
|
|
51
|
+
if both:
|
|
52
|
+
names = ", ".join(sorted(both))
|
|
53
|
+
msg = f"listed as both a condition and an action: {names}"
|
|
54
|
+
raise ConfigError(msg)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_config(path: str | Path) -> Config:
|
|
58
|
+
"""Read a YAML config file.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
path: The file to read.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The parsed configuration.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ConfigError: If the file cannot be read or is not a valid config.
|
|
68
|
+
"""
|
|
69
|
+
path = Path(path)
|
|
70
|
+
try:
|
|
71
|
+
data: Any = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
msg = f"cannot read config file {path}: {exc.strerror}"
|
|
74
|
+
raise ConfigError(msg) from exc
|
|
75
|
+
except yaml.YAMLError as exc:
|
|
76
|
+
msg = f"{path} is not valid YAML: {exc}"
|
|
77
|
+
raise ConfigError(msg) from exc
|
|
78
|
+
return config_from_dict(data, source=str(path), base_dir=path.parent)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def config_from_dict(
|
|
82
|
+
data: Any, *, source: str = "config", base_dir: str | Path | None = None
|
|
83
|
+
) -> Config:
|
|
84
|
+
"""Build a :class:`Config` from already-parsed data.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
data: The mapping read from YAML (``None`` means an empty file).
|
|
88
|
+
source: A name for error messages.
|
|
89
|
+
base_dir: What relative package directories are relative to: the
|
|
90
|
+
config file's folder. Defaults to the current directory.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
The configuration.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
ConfigError: If the data has the wrong shape.
|
|
97
|
+
"""
|
|
98
|
+
if data is None:
|
|
99
|
+
return Config()
|
|
100
|
+
if not isinstance(data, dict):
|
|
101
|
+
msg = f"{source}: expected a mapping at the top level"
|
|
102
|
+
raise ConfigError(msg)
|
|
103
|
+
unknown = sorted(set(data) - _KNOWN_KEYS)
|
|
104
|
+
if unknown:
|
|
105
|
+
msg = f"{source}: unknown key(s): {', '.join(map(str, unknown))}"
|
|
106
|
+
raise ConfigError(msg)
|
|
107
|
+
return Config(
|
|
108
|
+
conditions=_names(data.get("conditions"), "conditions", source),
|
|
109
|
+
actions=_names(data.get("actions"), "actions", source),
|
|
110
|
+
packages=_packages(data.get("packages"), source, Path(base_dir or ".")),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def find_config(directory: str | Path = ".") -> Path | None:
|
|
115
|
+
"""Return the default config file in ``directory``, if there is one."""
|
|
116
|
+
for name in DEFAULT_CONFIG_NAMES:
|
|
117
|
+
candidate = Path(directory) / name
|
|
118
|
+
if candidate.is_file():
|
|
119
|
+
return candidate
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _names(value: Any, key: str, source: str) -> frozenset[str]:
|
|
124
|
+
if value is None:
|
|
125
|
+
return frozenset()
|
|
126
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
127
|
+
msg = f"{source}: '{key}' must be a list of node names"
|
|
128
|
+
raise ConfigError(msg)
|
|
129
|
+
return frozenset(value)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _packages(value: Any, source: str, base_dir: Path) -> dict[str, Path]:
|
|
133
|
+
if value is None:
|
|
134
|
+
return {}
|
|
135
|
+
if not isinstance(value, dict) or not all(
|
|
136
|
+
isinstance(k, str) and isinstance(v, str) for k, v in value.items()
|
|
137
|
+
):
|
|
138
|
+
msg = f"{source}: 'packages' must map package names to directories"
|
|
139
|
+
raise ConfigError(msg)
|
|
140
|
+
return {name: base_dir / directory for name, directory in value.items()}
|
beehave/diagnostics.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Non-fatal findings reported alongside a result."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from beehave.model import SourceLocation
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Severity(str, Enum):
|
|
14
|
+
"""How much a diagnostic matters."""
|
|
15
|
+
|
|
16
|
+
INFO = "info"
|
|
17
|
+
WARNING = "warning"
|
|
18
|
+
ERROR = "error"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Diagnostic:
|
|
23
|
+
"""Something worth telling the user that does not stop the analysis.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
code: A stable, machine-readable identifier such as ``"approximated"``.
|
|
27
|
+
message: A plain-English explanation.
|
|
28
|
+
severity: How much it matters.
|
|
29
|
+
node_type: The node type the diagnostic is about, if any.
|
|
30
|
+
location: Where in the source it was found, if known.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
code: str
|
|
34
|
+
message: str
|
|
35
|
+
severity: Severity = Severity.WARNING
|
|
36
|
+
node_type: str | None = None
|
|
37
|
+
location: SourceLocation | None = field(default=None, compare=False)
|
|
38
|
+
|
|
39
|
+
def __str__(self) -> str:
|
|
40
|
+
where = f"{self.location}: " if self.location else ""
|
|
41
|
+
return f"{where}{self.severity.value}: {self.message} [{self.code}]"
|
beehave/errors.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Exceptions raised by Beehave."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from beehave.model import SourceLocation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BeehaveError(Exception):
|
|
12
|
+
"""Base class for every error Beehave raises on bad input."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, message: str, location: SourceLocation | None = None) -> None:
|
|
15
|
+
self.message = message
|
|
16
|
+
self.location = location
|
|
17
|
+
super().__init__(f"{location}: {message}" if location else message)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ParseError(BeehaveError):
|
|
21
|
+
"""A tree file could not be read."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UnsupportedFormatError(ParseError):
|
|
25
|
+
"""A tree file is in a format this version of Beehave cannot read yet."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConfigError(BeehaveError):
|
|
29
|
+
"""A Beehave configuration file is invalid."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FlattenError(BeehaveError):
|
|
33
|
+
"""Subtrees could not be inlined (missing or recursive subtree)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TreeSelectionError(BeehaveError):
|
|
37
|
+
"""No tree, or an unknown tree, was selected for analysis."""
|