rfem-table-export 0.2.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.
@@ -0,0 +1,19 @@
1
+ """RFEM 6 table extraction TUI.
2
+
3
+ A terminal UI that attaches to a running RFEM 6 process, lets the user pick
4
+ input and result tables from a tree, and exports them to a new Excel workbook
5
+ (one sheet per table plus an info sheet).
6
+ """
7
+
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+ try:
11
+ # Installed distribution (wheel/sdist, including via uvx/pip).
12
+ __version__ = version("rfem-table-export")
13
+ except PackageNotFoundError: # pragma: no cover
14
+ try:
15
+ # Built from source: hatch-vcs writes this file at build time.
16
+ from ._version import __version__
17
+ except ImportError:
18
+ # Running from an unbuilt checkout with no tags reachable.
19
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
+
24
+ __commit_id__ = commit_id = None
rfem_export/app.py ADDED
@@ -0,0 +1,262 @@
1
+ """Textual TUI for exporting RFEM 6 tables to Excel.
2
+
3
+ Flow:
4
+ 1. Connect to the running RFEM process and read the active model.
5
+ 2. Confirm the model in a banner (user verifies it's the right one).
6
+ 3. Pick input/result tables from a tree.
7
+ 4. Press 'e' to export -> a new timestamped workbook under results/.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+
14
+ from textual import work
15
+ from textual.app import App, ComposeResult
16
+ from textual.containers import Horizontal, Vertical
17
+ from textual.widgets import Footer, Header, Label, RichLog, Static, Tree
18
+ from textual.widgets.tree import TreeNode
19
+
20
+ from . import catalog, connection, excel, extract
21
+
22
+
23
+ class RfemExportApp(App):
24
+ CSS = """
25
+ #model_banner {
26
+ height: auto;
27
+ padding: 0 1;
28
+ background: $boost;
29
+ color: $text;
30
+ border: round $primary;
31
+ }
32
+ #body { height: 1fr; }
33
+ #tree_pane { width: 45%; border: round $secondary; }
34
+ #log_pane { width: 55%; border: round $secondary; }
35
+ #options { height: auto; padding: 0 1; color: $text-muted; }
36
+ """
37
+
38
+ BINDINGS = [
39
+ ("space", "toggle", "Toggle table"),
40
+ ("a", "select_all", "Select all"),
41
+ ("n", "select_none", "Clear"),
42
+ ("e", "export", "Export"),
43
+ ("r", "refresh", "Reconnect"),
44
+ ("q", "quit", "Quit"),
45
+ ]
46
+
47
+ def __init__(self, api_key: str | None = None) -> None:
48
+ super().__init__()
49
+ self._api_key = api_key
50
+ self._app = None # rfem.Application
51
+ self._model = connection.ModelInfo()
52
+ self._units = connection.UnitSummary()
53
+ self._selected: set[str] = set()
54
+ self._exporting = False
55
+
56
+ # -- layout ----------------------------------------------------------
57
+ def compose(self) -> ComposeResult:
58
+ yield Header(show_clock=True)
59
+ yield Static("Connecting to RFEM…", id="model_banner")
60
+ with Horizontal(id="body"):
61
+ with Vertical(id="tree_pane"):
62
+ yield self._build_tree()
63
+ with Vertical(id="log_pane"):
64
+ yield RichLog(id="log", highlight=True, markup=True, wrap=True)
65
+ yield Label("", id="options")
66
+ yield Footer()
67
+
68
+ def _build_tree(self) -> Tree:
69
+ tree: Tree = Tree("Available Tables", id="tree")
70
+ tree.root.expand()
71
+ for group in catalog.TREE:
72
+ self._add_group(tree.root, group)
73
+ return tree
74
+
75
+ def _add_group(self, parent: TreeNode, group: catalog.Group) -> None:
76
+ node = parent.add(group.name, expand=True, data=None)
77
+ for table in group.tables:
78
+ node.add_leaf(self._leaf_label(table.key, table.name), data=table.key)
79
+ for sub in group.subgroups:
80
+ self._add_group(node, sub)
81
+
82
+ def _leaf_label(self, key: str, name: str) -> str:
83
+ mark = "☑" if key in self._selected else "☐" # ☑ / ☐
84
+ return f"{mark} {name}"
85
+
86
+ # -- lifecycle -------------------------------------------------------
87
+ def on_mount(self) -> None:
88
+ self._refresh_options()
89
+ self.connect_rfem()
90
+
91
+ @work(thread=True)
92
+ def connect_rfem(self) -> None:
93
+ self._set_banner("Connecting to RFEM…")
94
+ try:
95
+ api_key = connection.resolve_api_key(self._api_key)
96
+ if not api_key:
97
+ self._log("[red]No API key found (config.ini / RFEM_API_KEY).[/red]")
98
+ self._app = connection.connect(api_key)
99
+ self._model = connection.get_model_info(self._app)
100
+ self._units = connection.get_unit_summary(self._app)
101
+ self._set_banner(self._banner_text())
102
+ self._log(
103
+ f"[green]Connected.[/green] Active model: [b]{self._model.name}[/b]"
104
+ )
105
+ self._log(f" Path: {self._model.path}")
106
+ self._log("Verify this is the correct model before exporting.")
107
+ except Exception as exc:
108
+ self._set_banner("[red]Not connected — RFEM not reachable[/red]")
109
+ self._log(f"[red]Connection failed:[/red] {exc}")
110
+ self._log("Is RFEM running with a model open? Press 'r' to retry.")
111
+
112
+ def _banner_text(self) -> str:
113
+ return (
114
+ f"[b]Model:[/b] {self._model.name or '(unknown)'} "
115
+ f"[b]Units:[/b] {self._units.system} "
116
+ f"[dim]{self._model.path}[/dim]"
117
+ )
118
+
119
+ # -- actions ---------------------------------------------------------
120
+ def action_toggle(self) -> None:
121
+ tree = self.query_one("#tree", Tree)
122
+ node = tree.cursor_node
123
+ if node is None or node.data is None:
124
+ return
125
+ key = node.data
126
+ if key in self._selected:
127
+ self._selected.discard(key)
128
+ else:
129
+ self._selected.add(key)
130
+ node.set_label(self._leaf_label(key, catalog.get_table(key).name))
131
+ self._refresh_options()
132
+
133
+ def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
134
+ # Enter/click on a leaf toggles it too.
135
+ if event.node.data is not None:
136
+ self.action_toggle()
137
+
138
+ def action_select_all(self) -> None:
139
+ self._set_all(True)
140
+
141
+ def action_select_none(self) -> None:
142
+ self._set_all(False)
143
+
144
+ def _set_all(self, selected: bool) -> None:
145
+ tree = self.query_one("#tree", Tree)
146
+ self._walk_leaves(tree.root, selected)
147
+ self._refresh_options()
148
+
149
+ def _walk_leaves(self, node: TreeNode, selected: bool) -> None:
150
+ for child in node.children:
151
+ key = child.data
152
+ if key is None:
153
+ self._walk_leaves(child, selected)
154
+ continue
155
+ if selected:
156
+ self._selected.add(key)
157
+ else:
158
+ self._selected.discard(key)
159
+ child.set_label(self._leaf_label(key, catalog.get_table(key).name))
160
+
161
+ def action_refresh(self) -> None:
162
+ self.connect_rfem()
163
+
164
+ def action_export(self) -> None:
165
+ if self._exporting:
166
+ self._log("[yellow]Export already in progress…[/yellow]")
167
+ return
168
+ if self._app is None:
169
+ self._log("[red]Not connected to RFEM. Press 'r' to connect.[/red]")
170
+ return
171
+ if not self._selected:
172
+ self._log(
173
+ "[yellow]No tables selected. Use space / 'a' to pick tables.[/yellow]"
174
+ )
175
+ return
176
+ self.run_export()
177
+
178
+ @work(thread=True)
179
+ def run_export(self) -> None:
180
+ self._exporting = True
181
+ try:
182
+ keys = [t.key for t in catalog.iter_tables() if t.key in self._selected]
183
+ self._log(f"[b]Exporting {len(keys)} table(s)…[/b]")
184
+
185
+ sheets: list[tuple[str, object]] = []
186
+ reports: list[excel.SheetReport] = []
187
+ for key in keys:
188
+ table = catalog.get_table(key)
189
+ self._log(f" • {table.name}…")
190
+ result = extract.extract(self._app, table)
191
+ for w in result.warnings:
192
+ self._log(f" [yellow]warn:[/yellow] {w}")
193
+ sheets.append((table.name, result.df))
194
+ reports.append(
195
+ excel.SheetReport(
196
+ table_name=table.name,
197
+ sheet_name=table.name, # final sheet name resolved in writer
198
+ rows=result.rows,
199
+ warnings=result.warnings,
200
+ )
201
+ )
202
+ self._log(f" {result.rows} row(s)")
203
+
204
+ path = excel.write_workbook(
205
+ model=self._model,
206
+ units=self._units,
207
+ scope="All elements",
208
+ sheets=sheets,
209
+ reports=reports,
210
+ )
211
+ self._log(f"[green]Export complete:[/green] {path}")
212
+ except Exception as exc:
213
+ self._log(f"[red]Export failed:[/red] {exc}")
214
+ finally:
215
+ self._exporting = False
216
+
217
+ # -- helpers ---------------------------------------------------------
218
+ def _refresh_options(self) -> None:
219
+ try:
220
+ label = self.query_one("#options", Label)
221
+ except Exception:
222
+ return
223
+ label.update(
224
+ f"Scope: All elements (v1) | "
225
+ f"Selected: {len(self._selected)} table(s) | "
226
+ f"space=toggle a=all n=none e=export r=reconnect q=quit"
227
+ )
228
+
229
+ def _set_banner(self, text: str) -> None:
230
+ self.call_from_thread(self._set_banner_sync, text)
231
+
232
+ def _set_banner_sync(self, text: str) -> None:
233
+ self.query_one("#model_banner", Static).update(text)
234
+
235
+ def _log(self, text: str) -> None:
236
+ self.call_from_thread(self._log_sync, text)
237
+
238
+ def _log_sync(self, text: str) -> None:
239
+ self.query_one("#log", RichLog).write(text)
240
+
241
+ def on_unmount(self) -> None:
242
+ if self._app is not None:
243
+ try:
244
+ self._app.close_connection()
245
+ except Exception:
246
+ pass
247
+
248
+
249
+ def main() -> None:
250
+ parser = argparse.ArgumentParser(description="RFEM 6 table export TUI")
251
+ parser.add_argument(
252
+ "--api-key",
253
+ dest="api_key",
254
+ default=None,
255
+ help="RFEM API key (defaults to RFEM_API_KEY then config.ini)",
256
+ )
257
+ args = parser.parse_args()
258
+ RfemExportApp(api_key=args.api_key).run()
259
+
260
+
261
+ if __name__ == "__main__":
262
+ main()
rfem_export/catalog.py ADDED
@@ -0,0 +1,266 @@
1
+ """Curated catalog of exportable RFEM tables, organized as a nested tree that
2
+ mirrors the RFEM 6 GUI navigators.
3
+
4
+ Hierarchy (top-level navigator -> category -> table), e.g.:
5
+
6
+ Structure
7
+ Basic Objects
8
+ Materials, Cross-Sections, Nodes, Lines, Members, Surfaces
9
+ Types for Nodes
10
+ Nodal Supports
11
+ Types for Members
12
+ Member Hinges
13
+ Load Cases and Combinations
14
+ Load Cases, Load Combinations, Result Combinations
15
+ Loads
16
+ Nodal Loads, Member Loads, Surface Loads
17
+ Results
18
+ Nodes / Members / Lines / Surfaces -> their result tables
19
+
20
+ Two kinds of tables:
21
+
22
+ * ``input`` — model objects, extracted by enumerating object IDs of a given
23
+ ``ObjectType`` and reading each object.
24
+ * ``result`` — analysis result tables, extracted per loading via
25
+ ``get_result_table``.
26
+
27
+ Add ``TableDef`` entries to the ``TREE`` below to extend coverage — extraction
28
+ and the TUI pick everything up automatically.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from dataclasses import dataclass, field
34
+
35
+ import dlubal.api.rfem as rfem
36
+ import dlubal.api.rfem.results as rfem_results
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class TableDef:
41
+ key: str # stable unique id
42
+ name: str # display name (also seeds the sheet name; must stay unique)
43
+ kind: str # "input" | "result"
44
+ # For inputs:
45
+ object_type: object | None = None # rfem.ObjectType.* enum value
46
+ proto: object | None = None # proto class, e.g. rfem.structure_core.Node
47
+ # For results:
48
+ result_table: object | None = None # rfem_results.ResultTable.* enum value
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Group:
53
+ """A navigator node: holds tables and/or nested subgroups."""
54
+
55
+ name: str
56
+ tables: tuple[TableDef, ...] = field(default_factory=tuple)
57
+ subgroups: tuple[Group, ...] = field(default_factory=tuple)
58
+
59
+
60
+ def _inp(key, name, object_type, proto) -> TableDef:
61
+ return TableDef(
62
+ key=key, name=name, kind="input", object_type=object_type, proto=proto
63
+ )
64
+
65
+
66
+ def _res(key, name, result_table) -> TableDef:
67
+ return TableDef(key=key, name=name, kind="result", result_table=result_table)
68
+
69
+
70
+ _RT = rfem_results.ResultTable
71
+ _OT = rfem.ObjectType
72
+
73
+
74
+ # --------------------------------------------------------------------------
75
+ # Tree definition — mirrors the RFEM 6 GUI navigators
76
+ # --------------------------------------------------------------------------
77
+
78
+ TREE: tuple[Group, ...] = (
79
+ Group(
80
+ "Structure",
81
+ subgroups=(
82
+ Group(
83
+ "Basic Objects",
84
+ tables=(
85
+ _inp(
86
+ "materials",
87
+ "Materials",
88
+ _OT.OBJECT_TYPE_MATERIAL,
89
+ rfem.structure_core.Material,
90
+ ),
91
+ _inp(
92
+ "cross_sections",
93
+ "Cross-Sections",
94
+ _OT.OBJECT_TYPE_CROSS_SECTION,
95
+ rfem.structure_core.CrossSection,
96
+ ),
97
+ _inp(
98
+ "nodes", "Nodes", _OT.OBJECT_TYPE_NODE, rfem.structure_core.Node
99
+ ),
100
+ _inp(
101
+ "lines", "Lines", _OT.OBJECT_TYPE_LINE, rfem.structure_core.Line
102
+ ),
103
+ _inp(
104
+ "members",
105
+ "Members",
106
+ _OT.OBJECT_TYPE_MEMBER,
107
+ rfem.structure_core.Member,
108
+ ),
109
+ _inp(
110
+ "surfaces",
111
+ "Surfaces",
112
+ _OT.OBJECT_TYPE_SURFACE,
113
+ rfem.structure_core.Surface,
114
+ ),
115
+ ),
116
+ ),
117
+ Group(
118
+ "Types for Nodes",
119
+ tables=(
120
+ _inp(
121
+ "nodal_supports",
122
+ "Nodal Supports",
123
+ _OT.OBJECT_TYPE_NODAL_SUPPORT,
124
+ rfem.types_for_nodes.NodalSupport,
125
+ ),
126
+ ),
127
+ ),
128
+ Group(
129
+ "Types for Members",
130
+ tables=(
131
+ _inp(
132
+ "member_hinges",
133
+ "Member Hinges",
134
+ _OT.OBJECT_TYPE_MEMBER_HINGE,
135
+ rfem.types_for_members.MemberHinge,
136
+ ),
137
+ ),
138
+ ),
139
+ ),
140
+ ),
141
+ Group(
142
+ "Load Cases and Combinations",
143
+ tables=(
144
+ _inp(
145
+ "load_cases",
146
+ "Load Cases",
147
+ _OT.OBJECT_TYPE_LOAD_CASE,
148
+ rfem.loading.LoadCase,
149
+ ),
150
+ _inp(
151
+ "load_combinations",
152
+ "Load Combinations",
153
+ _OT.OBJECT_TYPE_LOAD_COMBINATION,
154
+ rfem.loading.LoadCombination,
155
+ ),
156
+ _inp(
157
+ "result_combinations",
158
+ "Result Combinations",
159
+ _OT.OBJECT_TYPE_RESULT_COMBINATION,
160
+ rfem.loading.ResultCombination,
161
+ ),
162
+ ),
163
+ ),
164
+ Group(
165
+ "Loads",
166
+ tables=(
167
+ _inp(
168
+ "nodal_loads",
169
+ "Nodal Loads",
170
+ _OT.OBJECT_TYPE_NODAL_LOAD,
171
+ rfem.loads.NodalLoad,
172
+ ),
173
+ _inp(
174
+ "member_loads",
175
+ "Member Loads",
176
+ _OT.OBJECT_TYPE_MEMBER_LOAD,
177
+ rfem.loads.MemberLoad,
178
+ ),
179
+ _inp(
180
+ "surface_loads",
181
+ "Surface Loads",
182
+ _OT.OBJECT_TYPE_SURFACE_LOAD,
183
+ rfem.loads.SurfaceLoad,
184
+ ),
185
+ ),
186
+ ),
187
+ Group(
188
+ "Results",
189
+ subgroups=(
190
+ Group(
191
+ "Nodes",
192
+ tables=(
193
+ _res(
194
+ "res_node_support_forces",
195
+ "Nodal Support Forces",
196
+ _RT.STATIC_ANALYSIS_NODES_SUPPORT_FORCES_TABLE,
197
+ ),
198
+ _res(
199
+ "res_node_deformations",
200
+ "Nodal Deformations",
201
+ _RT.STATIC_ANALYSIS_NODES_GLOBAL_DEFORMATIONS_TABLE,
202
+ ),
203
+ ),
204
+ ),
205
+ Group(
206
+ "Members",
207
+ tables=(
208
+ _res(
209
+ "res_member_internal_forces",
210
+ "Member Internal Forces",
211
+ _RT.STATIC_ANALYSIS_MEMBERS_INTERNAL_FORCES_TABLE,
212
+ ),
213
+ _res(
214
+ "res_member_global_def",
215
+ "Member Global Deformations",
216
+ _RT.STATIC_ANALYSIS_MEMBERS_GLOBAL_DEFORMATIONS_TABLE,
217
+ ),
218
+ _res(
219
+ "res_member_local_def",
220
+ "Member Local Deformations",
221
+ _RT.STATIC_ANALYSIS_MEMBERS_LOCAL_DEFORMATIONS_TABLE,
222
+ ),
223
+ ),
224
+ ),
225
+ Group(
226
+ "Lines",
227
+ tables=(
228
+ _res(
229
+ "res_line_support_forces",
230
+ "Line Support Forces",
231
+ _RT.STATIC_ANALYSIS_LINES_SUPPORT_FORCES_TABLE,
232
+ ),
233
+ ),
234
+ ),
235
+ Group(
236
+ "Surfaces",
237
+ tables=(
238
+ _res(
239
+ "res_surface_internal_forces",
240
+ "Surface Internal Forces",
241
+ _RT.STATIC_ANALYSIS_SURFACES_BASIC_INTERNAL_FORCES_MESH_NODES_TABLE,
242
+ ),
243
+ ),
244
+ ),
245
+ ),
246
+ ),
247
+ )
248
+
249
+
250
+ # --------------------------------------------------------------------------
251
+ # Traversal helpers
252
+ # --------------------------------------------------------------------------
253
+
254
+
255
+ def iter_tables(groups: tuple[Group, ...] = TREE):
256
+ """Yield every TableDef in tree (display) order."""
257
+ for group in groups:
258
+ yield from group.tables
259
+ yield from iter_tables(group.subgroups)
260
+
261
+
262
+ _BY_KEY: dict[str, TableDef] = {t.key: t for t in iter_tables()}
263
+
264
+
265
+ def get_table(key: str) -> TableDef:
266
+ return _BY_KEY[key]
@@ -0,0 +1,147 @@
1
+ """RFEM connection, model identification, and unit-summary helpers.
2
+
3
+ This is the single source of truth for attaching to a running RFEM 6 process.
4
+ The API key is resolved from (in order): explicit argument, the RFEM_API_KEY
5
+ environment variable, then Dlubal's bundled ``config.ini``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import configparser
11
+ import os
12
+ import sys
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ # --- Dlubal bundled Python path -------------------------------------------
17
+ # When this app runs under an interpreter that doesn't already have the dlubal
18
+ # package importable, inject Dlubal's site-packages. Harmless if already present.
19
+ _DLUBAL_SITE_CANDIDATES = [
20
+ r"C:\Program Files\Dlubal\RFEM 6.14\bin\Lib\site-packages",
21
+ r"C:\Program Files\Dlubal\RFEM 6.13\bin\Lib\site-packages",
22
+ ]
23
+
24
+
25
+ def _ensure_dlubal_on_path() -> None:
26
+ try:
27
+ import dlubal.api.rfem # noqa: F401
28
+
29
+ return
30
+ except Exception:
31
+ pass
32
+ for candidate in _DLUBAL_SITE_CANDIDATES:
33
+ if os.path.isdir(candidate) and candidate not in sys.path:
34
+ sys.path.insert(0, candidate)
35
+
36
+
37
+ _ensure_dlubal_on_path()
38
+
39
+ import dlubal.api.rfem as rfem # noqa: E402
40
+
41
+ # config.ini lives next to the dlubal api package, regardless of RFEM version.
42
+ _CONFIG_INI = Path(rfem.__file__).resolve().parent.parent / "config.ini"
43
+
44
+
45
+ def _config_candidates() -> list[Path]:
46
+ """All config.ini locations to check, preferring the active package's."""
47
+ candidates = [_CONFIG_INI]
48
+ for site in _DLUBAL_SITE_CANDIDATES:
49
+ candidates.append(Path(site) / "dlubal" / "api" / "config.ini")
50
+ # de-dup, keep order
51
+ seen: set[Path] = set()
52
+ out: list[Path] = []
53
+ for c in candidates:
54
+ if c not in seen:
55
+ seen.add(c)
56
+ out.append(c)
57
+ return out
58
+
59
+
60
+ def read_api_key_from_config(config_path: Path | None = None) -> str | None:
61
+ """Read the 'default' API key from Dlubal's config.ini.
62
+
63
+ If ``config_path`` is given, only that file is checked. Otherwise every
64
+ known Dlubal install's config.ini is scanned (the key is version-specific
65
+ and may live under a different RFEM version than the active package).
66
+ """
67
+ paths = [config_path] if config_path is not None else _config_candidates()
68
+ for path in paths:
69
+ cfg = configparser.ConfigParser()
70
+ cfg.read(path)
71
+ key = cfg.get("api_keys", "default", fallback=None)
72
+ if key:
73
+ return key
74
+ return None
75
+
76
+
77
+ def resolve_api_key(explicit: str | None = None) -> str | None:
78
+ """Return API key from explicit arg, env var, or config.ini — in that order."""
79
+ if explicit:
80
+ return explicit
81
+ env = os.environ.get("RFEM_API_KEY")
82
+ if env:
83
+ return env
84
+ return read_api_key_from_config()
85
+
86
+
87
+ @dataclass
88
+ class ModelInfo:
89
+ name: str = ""
90
+ path: str = ""
91
+ guid: str = ""
92
+
93
+
94
+ @dataclass
95
+ class UnitSummary:
96
+ """A flat label->value view of the model's unit/format settings."""
97
+
98
+ system: str = "model" # we always export in the model's configured units
99
+ details: dict[str, str] = field(default_factory=dict)
100
+
101
+
102
+ def connect(api_key: str | None):
103
+ """Open a long-lived RFEM Application bound to the active model.
104
+
105
+ The caller owns the lifecycle; close it with ``app.close_connection()`` or
106
+ use it as a context manager. Raises on connection failure.
107
+ """
108
+ return rfem.Application(api_key_value=api_key)
109
+
110
+
111
+ def get_model_info(app) -> ModelInfo:
112
+ """Return name/path/guid of the active model.
113
+
114
+ ``get_model_list().model_info`` has been observed both as a repeated field
115
+ (indexable) and as a singular message, so we handle both shapes.
116
+ """
117
+ models = app.get_model_list()
118
+ mi = models.model_info
119
+ # repeated/indexable shape
120
+ try:
121
+ first = mi[0]
122
+ except (TypeError, KeyError, IndexError):
123
+ first = mi # singular message
124
+ return ModelInfo(
125
+ name=getattr(first, "name", "") or "",
126
+ path=getattr(first, "path", "") or "",
127
+ guid=getattr(first, "guid", "") or "",
128
+ )
129
+
130
+
131
+ def get_unit_summary(app) -> UnitSummary:
132
+ """Describe the units regime for the info sheet.
133
+
134
+ v1 does not convert units — every value is written exactly as the RFEM API
135
+ returns it, i.e. in the model's own configured units. RFEM does not expose a
136
+ unit table via BaseData, so this is a descriptive label plus a couple of
137
+ informational settings when available.
138
+ """
139
+ summary = UnitSummary(system="Model units (no conversion applied)")
140
+ try:
141
+ gs = app.get_base_data().general_settings
142
+ accel = getattr(gs, "gravitational_acceleration", None)
143
+ if accel:
144
+ summary.details["gravitational_acceleration"] = str(accel)
145
+ except Exception:
146
+ pass
147
+ return summary
rfem_export/excel.py ADDED
@@ -0,0 +1,161 @@
1
+ """Workbook writer: one sheet per exported table plus an info sheet.
2
+
3
+ Always writes a new timestamped file under ``results/``. Sheet formatting
4
+ (frozen header, auto-sized columns, named Excel Table) generalizes the pattern
5
+ from the original export_reactions.py proof-of-concept.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+
15
+ import pandas as pd
16
+ from openpyxl import Workbook
17
+ from openpyxl.utils import get_column_letter
18
+ from openpyxl.worksheet.table import Table as XlTable
19
+ from openpyxl.worksheet.table import TableStyleInfo
20
+
21
+ from .connection import ModelInfo, UnitSummary
22
+
23
+ OUTPUT_DIR = Path(__file__).resolve().parent.parent / "results"
24
+
25
+ _INVALID_SHEET_CHARS = re.compile(r"[\[\]\:\*\?\/\\]")
26
+
27
+
28
+ @dataclass
29
+ class SheetReport:
30
+ table_name: str
31
+ sheet_name: str
32
+ rows: int
33
+ warnings: list[str]
34
+
35
+
36
+ def _safe_filename(model_name: str) -> str:
37
+ cleaned = "".join(c if c.isalnum() or c in " ._-" else "_" for c in model_name)
38
+ return cleaned or "model"
39
+
40
+
41
+ def _sheet_name(raw: str, used: set[str]) -> str:
42
+ """Sanitize to Excel's rules (<=31 chars, no special chars, unique)."""
43
+ name = _INVALID_SHEET_CHARS.sub(" ", raw).strip() or "Sheet"
44
+ name = name[:31]
45
+ base = name
46
+ i = 2
47
+ while name.lower() in used:
48
+ suffix = f" ({i})"
49
+ name = base[: 31 - len(suffix)] + suffix
50
+ i += 1
51
+ used.add(name.lower())
52
+ return name
53
+
54
+
55
+ def _style_sheet(ws, table_display_name: str) -> None:
56
+ if ws.max_row < 1:
57
+ return
58
+ ws.freeze_panes = "A2"
59
+ for col_idx, col_cells in enumerate(ws.columns, start=1):
60
+ max_len = max(
61
+ (len(str(cell.value)) if cell.value is not None else 0)
62
+ for cell in col_cells
63
+ )
64
+ ws.column_dimensions[get_column_letter(col_idx)].width = min(max_len + 2, 40)
65
+
66
+ # Named Excel Table (only valid when there's at least a header + the table
67
+ # has columns). Display name must be unique and contain no spaces.
68
+ if ws.max_column >= 1 and ws.max_row >= 1:
69
+ ref = f"A1:{get_column_letter(ws.max_column)}{ws.max_row}"
70
+ disp = re.sub(r"\W", "_", table_display_name) or "Table"
71
+ disp = f"T_{disp}"[:255]
72
+ try:
73
+ xl = XlTable(displayName=disp, ref=ref)
74
+ xl.tableStyleInfo = TableStyleInfo(
75
+ name="TableStyleMedium9",
76
+ showRowStripes=True,
77
+ )
78
+ ws.add_table(xl)
79
+ except Exception:
80
+ pass # styling is best-effort; data is what matters
81
+
82
+
83
+ def _write_dataframe(ws, df: pd.DataFrame) -> None:
84
+ if df is None or df.empty:
85
+ ws.append(["(no data)"])
86
+ return
87
+ ws.append([str(c) for c in df.columns])
88
+ for row in df.itertuples(index=False, name=None):
89
+ ws.append([_cell(v) for v in row])
90
+
91
+
92
+ def _cell(v):
93
+ if v is None:
94
+ return None
95
+ if isinstance(v, (int, float, str, bool)):
96
+ return v
97
+ return str(v)
98
+
99
+
100
+ def write_workbook(
101
+ *,
102
+ model: ModelInfo,
103
+ units: UnitSummary,
104
+ scope: str,
105
+ sheets: list[tuple[str, pd.DataFrame]],
106
+ reports: list[SheetReport],
107
+ output_dir: Path = OUTPUT_DIR,
108
+ ) -> Path:
109
+ """Write all sheets + an info sheet to a new timestamped workbook."""
110
+ output_dir.mkdir(parents=True, exist_ok=True)
111
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
112
+ filename = output_dir / f"rfem_export_{_safe_filename(model.name)}_{timestamp}.xlsx"
113
+
114
+ wb = Workbook()
115
+ # First sheet is the info sheet.
116
+ info_ws = wb.active
117
+ info_ws.title = "Export Info"
118
+ _write_info_sheet(info_ws, model, units, scope, reports, timestamp)
119
+
120
+ used = {"export info"}
121
+ for table_display_name, df in sheets:
122
+ ws = wb.create_sheet(title=_sheet_name(table_display_name, used))
123
+ _write_dataframe(ws, df)
124
+ _style_sheet(ws, table_display_name)
125
+
126
+ wb.save(filename)
127
+ return filename
128
+
129
+
130
+ def _write_info_sheet(
131
+ ws,
132
+ model: ModelInfo,
133
+ units: UnitSummary,
134
+ scope: str,
135
+ reports: list[SheetReport],
136
+ timestamp: str,
137
+ ) -> None:
138
+ ws.append(["RFEM Table Export"])
139
+ ws.append([])
140
+ ws.append(["Model name", model.name])
141
+ ws.append(["Model path", model.path])
142
+ ws.append(["Model GUID", model.guid])
143
+ ws.append(["Exported at", timestamp])
144
+ ws.append(["Scope", scope])
145
+ ws.append(["Unit system", units.system])
146
+ if units.details:
147
+ ws.append([])
148
+ ws.append(["Unit settings"])
149
+ for k, v in units.details.items():
150
+ ws.append([k, v])
151
+
152
+ ws.append([])
153
+ ws.append(["Exported tables"])
154
+ ws.append(["Table", "Sheet", "Rows", "Warnings"])
155
+ for r in reports:
156
+ ws.append([r.table_name, r.sheet_name, r.rows, " | ".join(r.warnings)])
157
+
158
+ ws.column_dimensions["A"].width = 22
159
+ ws.column_dimensions["B"].width = 30
160
+ ws.column_dimensions["C"].width = 10
161
+ ws.column_dimensions["D"].width = 80
rfem_export/extract.py ADDED
@@ -0,0 +1,205 @@
1
+ """Extraction of input objects and result tables into pandas DataFrames.
2
+
3
+ Every extractor returns an ``ExtractResult`` carrying the DataFrame plus a list
4
+ of human-readable warnings, so a single problematic table can never abort the
5
+ whole export.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ import pandas as pd
13
+ from google.protobuf.json_format import MessageToDict
14
+
15
+ # Import order matters: importing ``connection`` injects Dlubal's bundled
16
+ # site-packages onto sys.path, which must happen before ``dlubal.api.rfem`` is
17
+ # imported. Do not let isort reorder these (see per-file-ignores in pyproject).
18
+ from . import connection # noqa: F401 (side effect: makes dlubal importable)
19
+ import dlubal.api.rfem as rfem # noqa: E402
20
+
21
+ from .catalog import TableDef
22
+
23
+
24
+ @dataclass
25
+ class ExtractResult:
26
+ df: pd.DataFrame = field(default_factory=pd.DataFrame)
27
+ warnings: list[str] = field(default_factory=list)
28
+
29
+ @property
30
+ def rows(self) -> int:
31
+ return 0 if self.df is None else len(self.df)
32
+
33
+
34
+ # --------------------------------------------------------------------------
35
+ # Input objects
36
+ # --------------------------------------------------------------------------
37
+
38
+
39
+ def _object_ids(app, object_type) -> list[int]:
40
+ id_list = app.get_object_id_list(object_type=object_type)
41
+ return [oid.no for oid in id_list.object_id]
42
+
43
+
44
+ # Some objects (notably loads) are children of another object and cannot be
45
+ # read without naming that parent on the proto. Map the parent's ObjectType to
46
+ # the proto field that addresses it.
47
+ _PARENT_FIELD_BY_TYPE = {
48
+ rfem.ObjectType.OBJECT_TYPE_LOAD_CASE: "load_case",
49
+ rfem.ObjectType.OBJECT_TYPE_LOAD_COMBINATION: "load_combination",
50
+ }
51
+
52
+
53
+ def _object_id_entries(app, object_type) -> list:
54
+ """Return the full object_id messages (carry no + parent info)."""
55
+ id_list = app.get_object_id_list(object_type=object_type)
56
+ return list(id_list.object_id)
57
+
58
+
59
+ def _build_proto(proto, oid):
60
+ """Construct a proto for get_object, setting any required parent field."""
61
+ kwargs = {"no": oid.no}
62
+ parent_no = getattr(oid, "parent_no", 0)
63
+ if parent_no:
64
+ field = _PARENT_FIELD_BY_TYPE.get(getattr(oid, "parent_object_type", None))
65
+ # Only set the parent field if the proto actually declares it.
66
+ if field and field in {f.name for f in proto.DESCRIPTOR.fields}:
67
+ kwargs[field] = parent_no
68
+ return proto(**kwargs)
69
+
70
+
71
+ def _proto_to_row(msg) -> dict:
72
+ """Flatten a protobuf message to a single flat dict row."""
73
+ d = MessageToDict(
74
+ msg,
75
+ preserving_proto_field_name=True,
76
+ use_integers_for_enums=False,
77
+ )
78
+ return d
79
+
80
+
81
+ def extract_input(app, table: TableDef) -> ExtractResult:
82
+ res = ExtractResult()
83
+ try:
84
+ entries = _object_id_entries(app, table.object_type)
85
+ except Exception as exc:
86
+ res.warnings.append(f"{table.name}: could not list object IDs — {exc}")
87
+ return res
88
+
89
+ if not entries:
90
+ res.warnings.append(f"{table.name}: no objects of this type in the model")
91
+ return res
92
+
93
+ rows: list[dict] = []
94
+ failures = 0
95
+ last_error = ""
96
+ for oid in entries:
97
+ try:
98
+ obj = app.get_object(_build_proto(table.proto, oid))
99
+ rows.append(_proto_to_row(obj))
100
+ except Exception as exc:
101
+ failures += 1
102
+ last_error = (
103
+ str(exc).splitlines()[0] if str(exc) else exc.__class__.__name__
104
+ )
105
+
106
+ if failures:
107
+ res.warnings.append(
108
+ f"{table.name}: {failures} of {len(entries)} object(s) could not be read"
109
+ + (f" — {last_error}" if last_error else "")
110
+ )
111
+
112
+ if not rows:
113
+ res.warnings.append(f"{table.name}: no readable objects")
114
+ return res
115
+
116
+ res.df = pd.json_normalize(rows)
117
+ return res
118
+
119
+
120
+ # --------------------------------------------------------------------------
121
+ # Result tables (iterated over every loading)
122
+ # --------------------------------------------------------------------------
123
+
124
+ _LOADING_SPECS = [
125
+ ("Load Case", rfem.ObjectType.OBJECT_TYPE_LOAD_CASE, rfem.loading.LoadCase),
126
+ (
127
+ "Load Combination",
128
+ rfem.ObjectType.OBJECT_TYPE_LOAD_COMBINATION,
129
+ rfem.loading.LoadCombination,
130
+ ),
131
+ (
132
+ "Result Combination",
133
+ rfem.ObjectType.OBJECT_TYPE_RESULT_COMBINATION,
134
+ rfem.loading.ResultCombination,
135
+ ),
136
+ ]
137
+
138
+
139
+ def _list_loadings(app) -> list[tuple[str, int, str, object]]:
140
+ """Return (label, no, name, object_type) for every load case/combination."""
141
+ out: list[tuple[str, int, str, object]] = []
142
+ for label, obj_type, proto in _LOADING_SPECS:
143
+ try:
144
+ ids = _object_ids(app, obj_type)
145
+ except Exception:
146
+ continue
147
+ for no in ids:
148
+ try:
149
+ obj = app.get_object(proto(no=no))
150
+ name = getattr(obj, "name", "") or ""
151
+ except Exception:
152
+ name = ""
153
+ out.append((label, no, name, obj_type))
154
+ return out
155
+
156
+
157
+ def extract_result(app, table: TableDef) -> ExtractResult:
158
+ res = ExtractResult()
159
+
160
+ try:
161
+ hr = app.has_results()
162
+ has = getattr(hr, "value", hr) # has_results() returns a BoolValue wrapper
163
+ if not has:
164
+ res.warnings.append(f"{table.name}: model has no analysis results")
165
+ return res
166
+ except Exception as exc:
167
+ res.warnings.append(f"{table.name}: has_results() failed — {exc}")
168
+ return res
169
+
170
+ loadings = _list_loadings(app)
171
+ if not loadings:
172
+ res.warnings.append(f"{table.name}: no load cases or combinations found")
173
+ return res
174
+
175
+ frames: list[pd.DataFrame] = []
176
+ for loading_type, no, name, obj_type in loadings:
177
+ label = f"{loading_type} {no} ({name})"
178
+ try:
179
+ loading_id = rfem.ObjectId(no=no, object_type=obj_type)
180
+ result_table = app.get_result_table(table.result_table, loading_id)
181
+ data = getattr(result_table, "data", None)
182
+ if data is None or len(data) == 0:
183
+ continue
184
+ df = data.copy()
185
+ df.insert(0, "loading_type", loading_type)
186
+ df.insert(1, "loading_no", no)
187
+ df.insert(2, "loading_name", name)
188
+ frames.append(df)
189
+ except Exception as exc:
190
+ res.warnings.append(f"{table.name}: {label} skipped — {exc}")
191
+
192
+ if not frames:
193
+ res.warnings.append(f"{table.name}: no result rows across any loading")
194
+ return res
195
+
196
+ res.df = pd.concat(frames, ignore_index=True)
197
+ return res
198
+
199
+
200
+ def extract(app, table: TableDef) -> ExtractResult:
201
+ if table.kind == "input":
202
+ return extract_input(app, table)
203
+ if table.kind == "result":
204
+ return extract_result(app, table)
205
+ return ExtractResult(warnings=[f"{table.name}: unknown table kind '{table.kind}'"])
@@ -0,0 +1,199 @@
1
+ Metadata-Version: 2.4
2
+ Name: rfem-table-export
3
+ Version: 0.2.0
4
+ Summary: Terminal UI for exporting RFEM 6 input and result tables to Excel
5
+ Project-URL: Homepage, https://github.com/Mark-Milkis/rfem-table-export
6
+ Project-URL: Repository, https://github.com/Mark-Milkis/rfem-table-export
7
+ Project-URL: Issues, https://github.com/Mark-Milkis/rfem-table-export/issues
8
+ Author-email: Mark Milkis <markmilkis@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: dlubal,engineering,excel,export,rfem,structural,tui
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: Microsoft :: Windows
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Scientific/Engineering
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: dlubal-api==2.14.3
23
+ Requires-Dist: openpyxl>=3.1
24
+ Requires-Dist: pandas>=2.0
25
+ Requires-Dist: textual>=0.80
26
+ Description-Content-Type: text/markdown
27
+
28
+ # rfem-table-export
29
+
30
+ A terminal UI (TUI) for exporting [RFEM 6](https://www.dlubal.com/en/products/rfem-fea-software/what-is-rfem)
31
+ tables — both model **inputs** and analysis **results** — to a formatted Excel
32
+ workbook. It attaches to an already-running RFEM 6 process over the Dlubal gRPC
33
+ API, lets you confirm the active model, pick tables from a tree that mirrors the
34
+ RFEM GUI navigators, and writes a new timestamped `.xlsx` with one sheet per
35
+ table plus an info sheet capturing export metadata and any warnings.
36
+
37
+ The table-picker UX is modeled on SAP2000's "Export Tables to Excel" dialog.
38
+
39
+ ## Features
40
+
41
+ - **Attach to a live model** — connects to the running RFEM 6 process and shows
42
+ the active model's name and path so you can verify you're exporting the right
43
+ one before you start.
44
+ - **Navigator-style table tree** — tables are organized exactly like the RFEM
45
+ GUI (Structure → Basic Objects → Nodes, …; Load Cases and Combinations; Loads;
46
+ Results → Nodes/Members/Lines/Surfaces).
47
+ - **Inputs and results** — model objects (nodes, members, surfaces, materials,
48
+ cross-sections, supports, hinges, load cases/combinations, loads) and static
49
+ result tables (support forces, internal forces, deformations) iterated across
50
+ every load case and combination.
51
+ - **Model units, no surprises** — values are written exactly as the RFEM API
52
+ returns them (the model's own units); the info sheet records this.
53
+ - **One workbook per export** — a new file is always written to `results/`;
54
+ nothing is overwritten.
55
+ - **Resilient** — a single problematic table produces a warning on the info
56
+ sheet rather than aborting the whole export.
57
+
58
+ ## Requirements
59
+
60
+ - Windows with **RFEM 6.14** installed and running, a model open, and the gRPC
61
+ web service / API enabled.
62
+ - A valid Dlubal API key (see [Configuration](#configuration)).
63
+ - Python 3.11+.
64
+
65
+ > **Version pin:** the `dlubal-api` client version must match the RFEM server
66
+ > version. This project pins `dlubal-api==2.14.3` for **RFEM 6.14**. If you run a
67
+ > different RFEM version, change the pin to match — mismatched versions cause
68
+ > object-type enum drift and silently wrong/empty exports.
69
+
70
+ ## Installation
71
+
72
+ ### Run without installing (recommended)
73
+
74
+ With [uv](https://docs.astral.sh/uv/) you can run the latest published release
75
+ directly — no clone, no virtualenv:
76
+
77
+ ```bash
78
+ uvx rfem-table-export
79
+ ```
80
+
81
+ Pin a specific version (handy for matching your RFEM/`dlubal-api` version):
82
+
83
+ ```bash
84
+ uvx rfem-table-export@0.2.0
85
+ ```
86
+
87
+ > The console command is also available as `rfem-export`; with `uvx` use the
88
+ > distribution name (`rfem-table-export`) so version pinning works.
89
+
90
+ ### From a clone (for development)
91
+
92
+ ```bash
93
+ uv sync
94
+ ```
95
+
96
+ Or with pip:
97
+
98
+ ```bash
99
+ pip install -e .
100
+ ```
101
+
102
+ ## Usage
103
+
104
+ 1. Start RFEM 6, open and (for result tables) solve your model.
105
+ 2. Launch the TUI:
106
+
107
+ ```bash
108
+ uv run rfem-export
109
+ # or
110
+ python -m rfem_export.app
111
+ ```
112
+
113
+ 3. Confirm the banner shows the correct active model.
114
+ 4. Navigate the tree and toggle the tables you want:
115
+
116
+ | Key | Action |
117
+ |---------|-------------------|
118
+ | `space` | Toggle table |
119
+ | `a` | Select all |
120
+ | `n` | Clear selection |
121
+ | `e` | Export |
122
+ | `r` | Reconnect to RFEM |
123
+ | `q` | Quit |
124
+
125
+ 5. Press `e`. The workbook is written to `results/rfem_export_<model>_<timestamp>.xlsx`.
126
+
127
+ ## Configuration
128
+
129
+ The API key is resolved in this order:
130
+
131
+ 1. `--api-key` command-line argument
132
+ 2. `RFEM_API_KEY` environment variable
133
+ 3. Dlubal's bundled `config.ini` (`[api_keys] default = …`), scanned across
134
+ installed RFEM versions
135
+
136
+ ```bash
137
+ uv run rfem-export --api-key YOUR_KEY
138
+ ```
139
+
140
+ ## Project layout
141
+
142
+ ```
143
+ rfem_export/
144
+ __init__.py
145
+ connection.py # API-key resolution, attach to RFEM, model info, unit summary
146
+ catalog.py # curated navigator tree of exportable tables (TableDef / Group)
147
+ extract.py # protobuf objects + result tables -> pandas DataFrames
148
+ excel.py # workbook writer: one sheet per table + info sheet
149
+ app.py # Textual TUI + console entry point
150
+ examples/
151
+ export_reactions.py # original single-table proof-of-concept
152
+ ```
153
+
154
+ ### Extending table coverage
155
+
156
+ Add a `TableDef` to the `TREE` in [`rfem_export/catalog.py`](rfem_export/catalog.py).
157
+ Both extraction and the TUI pick up new entries automatically — inputs are
158
+ extracted by enumerating object IDs of an `ObjectType`; results via
159
+ `get_result_table` across every loading.
160
+
161
+ ## Development
162
+
163
+ Lint and format with [ruff](https://docs.astral.sh/ruff/) (the same checks CI
164
+ runs):
165
+
166
+ ```bash
167
+ uvx ruff check . # lint
168
+ uvx ruff format --check . # formatting
169
+ uvx ruff format . # auto-format
170
+ ```
171
+
172
+ ## Versioning & releases
173
+
174
+ The package version is **derived automatically from git tags** via
175
+ [`hatch-vcs`](https://github.com/ofek/hatch-vcs) — there is no hardcoded
176
+ version to bump. Tag `vX.Y.Z` becomes version `X.Y.Z`.
177
+
178
+ To cut a release:
179
+
180
+ ```bash
181
+ git tag v0.2.0
182
+ git push origin v0.2.0
183
+ ```
184
+
185
+ Pushing the tag triggers the **Release** workflow, which lints, builds the
186
+ sdist/wheel, verifies the built version matches the tag, publishes to
187
+ [PyPI](https://pypi.org/p/rfem-table-export), and creates a GitHub release.
188
+
189
+ > **One-time PyPI setup:** publishing uses
190
+ > [Trusted Publishing (OIDC)](https://docs.pypi.org/trusted-publishers/), so no
191
+ > API token is stored. On PyPI, add a trusted publisher for this project with:
192
+ > owner `Mark-Milkis`, repository `rfem-table-export`, workflow
193
+ > `release.yml`, and environment `pypi`. (For the very first publish you may
194
+ > need to use the "pending publisher" flow since the project doesn't exist on
195
+ > PyPI yet.)
196
+
197
+ ## License
198
+
199
+ [MIT](LICENSE)
@@ -0,0 +1,12 @@
1
+ rfem_export/__init__.py,sha256=WJpWLZl_t-PapyWMeoA48U5IhJX9tra33ZRaC4MblBE,715
2
+ rfem_export/_version.py,sha256=s9U3X54Pdr-Jlh9GP6LBaa_VRos7qs_wtrVefUHHNIA,520
3
+ rfem_export/app.py,sha256=1cXsU3SPhTY5NO5c6t8w_2kJGLgXEO9c6UXe4FDsN4E,9283
4
+ rfem_export/catalog.py,sha256=iQepQBZ6ifmQrDwqHYcm-lhcvZ1EOdRerZ1dbfqCaBI,8379
5
+ rfem_export/connection.py,sha256=9y60AU-5OLNDkK7rXlqlzPyj0JuNSwwtOMZUBIGvotQ,4759
6
+ rfem_export/excel.py,sha256=IGidIH18Sgl1nYmJDW6uVZ4AVpDInS9P526dz0GfSaU,4893
7
+ rfem_export/extract.py,sha256=ShqExN2WNNhpTPbAuScbdooJBHOu7u99NX1Lz5JmdYs,6846
8
+ rfem_table_export-0.2.0.dist-info/METADATA,sha256=Gs_1DTMlrrWW_PtPhEDd8_aj8JRhpsqBp7V0LxLdYLw,6804
9
+ rfem_table_export-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ rfem_table_export-0.2.0.dist-info/entry_points.txt,sha256=av0dTvJqoTQkzCNQCD0O1K9tY-s913MwcE5b4rNtgX4,94
11
+ rfem_table_export-0.2.0.dist-info/licenses/LICENSE,sha256=CQtzw8-Piq2t7Gl6mCWuVeg8fKoNOL57AeHyi6etPfY,1068
12
+ rfem_table_export-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ rfem-export = rfem_export.app:main
3
+ rfem-table-export = rfem_export.app:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark Milkis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.