Compare commits
6
Commits
3e313f8223
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a99fa1b87 | ||
|
|
2dcc1704a6 | ||
|
|
1fdfed90dd | ||
|
|
af906b31dc | ||
|
|
dd7b586ea0 | ||
|
|
ec4019ea96 |
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*-sab2sax-*.sab
|
||||
*-sab2sax-*.sax
|
||||
@@ -1,26 +1,151 @@
|
||||
# PySedonac
|
||||
|
||||
Pure-Python `.sax` -> `.sab` encoder. No Java, no JRE.
|
||||
Pure-Python Sedona app converter, both directions — `.sax` -> `.sab` and `.sab` -> `.sax`. No Java,
|
||||
no JRE.
|
||||
|
||||
Ported from the Sedona 1.2 runtime library — `sedona/src/sedona/src/sedona/offline/OfflineApp.java`
|
||||
and friends, **not** from `sedonac/` (that's the language compiler; app file conversion lives in the
|
||||
runtime lib).
|
||||
|
||||
Verified byte-identical to `sedonac.exe` on a 189-component / 217-link app across 18 kits.
|
||||
Verified byte-identical to `sedonac.exe` in both directions on a 189-component / 217-link app across
|
||||
18 kits.
|
||||
|
||||
## Usage
|
||||
## Run it
|
||||
|
||||
python sab.py <app.sax> <sedona_home> <out.sab>
|
||||
python run.py the window
|
||||
pythonw run.py the window with no console behind it
|
||||
python run.py app.sab [out] straight to the command line converter
|
||||
|
||||
`sedona_home` is the directory holding `manifests/` — kit manifests are required, since slot ids and
|
||||
types are resolved from them.
|
||||
`run.py` is the single entry point: no arguments opens the GUI, any argument is handed to the
|
||||
command line converter. Double-click it (or a shortcut to `pythonw run.py`) and you get the window.
|
||||
|
||||
## The window
|
||||
|
||||
python run.py (or: python gui.py)
|
||||
|
||||
Title bar carries the name and version (`SAB - SAX Sedona Files Converter 0.0.0.002`). One button
|
||||
per direction — **SAB file to SAX file** and **SAX file to SAB file** — and the button you press
|
||||
decides which extension the file chooser offers. The log shows the chosen file and its folder
|
||||
*before* the conversion runs, then the output file and its folder when it finishes:
|
||||
|
||||
sab -> sax
|
||||
INPUT FILE:
|
||||
app.sab
|
||||
INPUT PATH:
|
||||
C:\apps
|
||||
OUTPUT FILE:
|
||||
app-sab2sax-20260728-140609.sax
|
||||
OUTPUT PATH:
|
||||
C:\apps
|
||||
wrote 61294 bytes
|
||||
|
||||
The result is written next to the input under that timestamped name, so nothing is overwritten and
|
||||
no save dialog gets in the way. Sedona home is shown at the top with a `Change...` button that
|
||||
rewrites `system.properties`.
|
||||
|
||||
## The command line
|
||||
|
||||
python run.py [in.sax|in.sab] [out] (or: python sab2sax.py ...)
|
||||
|
||||
Like `sedonac`, the direction follows the input's extension: feed it a `.sax` and you get a `.sab`,
|
||||
feed it a `.sab` and you get a `.sax`. Both arguments are optional — `sab2sax.py` on its own asks for
|
||||
what it needs:
|
||||
|
||||
* no input → a file chooser opens, listing `.sax` and `.sab`;
|
||||
* no output → a save dialog opens next to the input, pre-filled with a timestamped name:
|
||||
`app.sax` → `app-sab2sax-20260728-140609.sab`, so a run never overwrites the previous one.
|
||||
|
||||
`python sab.py ...` still works and does the same thing — it hands off to `sab2sax.py`.
|
||||
|
||||
Sedona home is *not* an argument — it comes from `system.properties` (below). As a library:
|
||||
|
||||
import sab2sax
|
||||
sab2sax.convert("app.sax") # -> ("app-sab2sax-<stamp>.sab", nbytes)
|
||||
sab2sax.convert("app.sab", "out.sax") # explicit output, no dialogs
|
||||
|
||||
import sab, sax
|
||||
sab.encode("app.sax", home, "out.sab") # one direction each, if you prefer
|
||||
sax.decode("app.sab", home, "out.sax") # `home=None` reads system.properties
|
||||
|
||||
### What a `.sab` cannot give back
|
||||
|
||||
Only **config** props are stored in the binary, so a `.sab` -> `.sax` conversion cannot recover
|
||||
runtime prop values — they come back as their manifest defaults. `sedonac` has exactly the same hole;
|
||||
this is a property of the format, not of the port. (A prop equal to its default is not written to the
|
||||
`.sax` at all, by either tool.)
|
||||
|
||||
## Configuration
|
||||
|
||||
Sedona home lives in `system.properties`, next to the scripts, under the same key sedonac uses:
|
||||
|
||||
sedona.home=C:\path\to\sedona
|
||||
|
||||
It must be the directory holding `manifests/` — kit manifests are required, since slot ids and types
|
||||
are resolved from them.
|
||||
|
||||
Plain Windows paths, no escaping. Java's `Properties` — what sedonac itself reads this file with —
|
||||
treats `\` as an escape and so doubles them up; that form parses here too. The rule is `\\` collapses
|
||||
to `\` and every other backslash is kept literally, so `C:\niagara` never turns into a newline.
|
||||
|
||||
If the key is missing, or the folder it names has no `manifests/`, a folder chooser opens on first
|
||||
run and the choice is written back to `system.properties` (other lines and comments are preserved; a
|
||||
duplicate `sedona.home` further down gets commented out). Dialogs need `tkinter` — on a headless box
|
||||
set the key by hand instead.
|
||||
|
||||
`python config.py` prints the resolved home, prompting if it isn't set yet.
|
||||
|
||||
## Version
|
||||
|
||||
`config.NAME` / `config.VERSION` — currently `SAB - SAX Sedona Files Converter 0.0.0.002`. The window
|
||||
title and every dialog title bar carry it; a chooser reads `SAB - SAX Sedona Files Converter
|
||||
0.0.0.002 - Select the .sax or .sab application file to convert`, and the CLI prints the same line
|
||||
when it starts, so it is obvious which build produced a file. Bump it in [config.py](config.py) and
|
||||
all of them follow.
|
||||
|
||||
## Verify
|
||||
|
||||
python verify.py [sedona_home]
|
||||
python verify.py [app.sax|app.sab] [sedona_home]
|
||||
|
||||
Compares our output against the checked-in `test_normal_sedonac.sab`. Pass a sedona home containing
|
||||
`bin/sedonac.exe` and call `check(sax)` with no reference to diff against a live sedonac run instead.
|
||||
Point it at any app of yours and it round-trips it, direction by extension:
|
||||
|
||||
app.sab -> sab -> sax -> sab must come back byte-identical
|
||||
app.sax -> sax -> sab -> sax -> sab the two sabs must be identical
|
||||
|
||||
The `.sax` case compares the two **binaries**, not the two texts, because going through a `.sab` is
|
||||
lossy on purpose — runtime prop values and formatting are not in the binary. If the regenerated
|
||||
`.sax` differs from the original, that is printed as a note rather than a failure. (For a `.sax`
|
||||
sedonac itself wrote, they do match.)
|
||||
|
||||
With no file argument a chooser opens, listing `.sax` and `.sab`. Cancel it and the built-in
|
||||
regression runs instead, against the `test_normal.sax` / `test_normal_sedonac.sab` pair checked in
|
||||
under `testprogram/`. Remove them and verify prints `SKIP` and exits 0 instead of failing:
|
||||
|
||||
test_normal.sax -> sab vs test_normal_sedonac.sab
|
||||
test_normal_sedonac.sab -> sax vs test_normal.sax
|
||||
both round trips as above
|
||||
|
||||
`check_encode(sax)` / `check_decode(sab)` with no reference argument run `bin/sedonac.exe` on a copy
|
||||
of the input and diff against that instead — a live comparison, if you have a JRE it will start
|
||||
under.
|
||||
|
||||
Exit code is 0 on pass, 1 on failure. A malformed file fails with a one-line reason
|
||||
(`comp 0 app: missing child 254`) rather than a traceback.
|
||||
|
||||
## Files
|
||||
|
||||
run.py entry point: window with no arguments, CLI with them
|
||||
gui.py the window: a button per direction
|
||||
sab2sax.py the CLI: dispatches on the input extension
|
||||
sab.py sax -> sab encoder
|
||||
sax.py sab -> sax decoder
|
||||
config.py system.properties read/write, folder + file choosers
|
||||
verify.py regression check against sedonac's output, both directions
|
||||
system.properties sedona.home
|
||||
testprogram/ test apps verify.py runs against
|
||||
claudeCodeChatLog.md how this got built, and why
|
||||
|
||||
Conversion output (`*-sab2sax-*.sax`, `*-sab2sax-*.sab`) is gitignored, so converting inside
|
||||
`testprogram/` leaves the repo clean.
|
||||
|
||||
## Format
|
||||
|
||||
@@ -60,12 +185,32 @@ attribute, or failing that `Value.defaultForType` -> **zero**. `null` (NaN, `0x7
|
||||
only when the SAX literally says `val="null"`. This was the single bug between "same length" and
|
||||
"byte identical".
|
||||
|
||||
## Going back: sab -> sax
|
||||
|
||||
The tree is rebuilt from the `firstChild`/`nextSibling` id chain, then written the way
|
||||
`OfflineApp.encodeAppXml` does: two-space indent per level, a `<!-- /path -->` comment above every
|
||||
`<comp>`, and a prop line only where the value differs from the slot default.
|
||||
|
||||
Two details worth knowing:
|
||||
|
||||
**Java float formatting.** `Value.encodeString` for a float is `java.lang.Float.toString` — shortest
|
||||
digits that round-trip, always a decimal point, `E` notation outside `[1e-3, 1e7)`. `sax.java_float`
|
||||
reproduces it (`2500.0`, `1.0E7`, `1.0E-4`).
|
||||
|
||||
**The default is a 32-bit float too.** Comparing the decoded value against a manifest default parsed
|
||||
as a Python double makes `0.1` look different from `0.1` and writes out props that sedonac omits —
|
||||
round the default to `f4` first.
|
||||
|
||||
## Not covered
|
||||
|
||||
Only what `test_normal.sax` exercises is proven. Untested: action overrides, `Buf`-typed props
|
||||
Only what `testprogram/test_normal.sax` exercises is proven. Untested: action overrides, `Buf`-typed props
|
||||
(base64 path is written but unexercised), non-ASCII strings (Sedona `Str` is ASCII-only and will
|
||||
raise), and apps whose component ids exceed the 256-entry lookup table.
|
||||
|
||||
Also, `java_float` matches modern Java, not the old JDK's extra-digit quirk on subnormals — Java
|
||||
prints `Float.MIN_VALUE` as `1.4E-45` where we print the equally round-trippable `1.0E-45`. Only
|
||||
reachable with denormal floats in an app.
|
||||
|
||||
## Alternative
|
||||
|
||||
If a JRE is present, `sedona/bin/sedonac.exe <file.sax|.sab>` converts either direction off the file
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Claude Code session log
|
||||
|
||||
What was asked for, what was built, and the decisions behind it. Written 2026-08-13, covering the
|
||||
work that produced `ec4019e` .. `2dcc170` (tags `v0.0.0.001`, `v0.0.0.002`).
|
||||
|
||||
Starting point: `sab.py`, a pure-Python `.sax` -> `.sab` encoder taking `<app.sax> <sedona_home>
|
||||
<out.sab>` on the command line, plus `verify.py`.
|
||||
|
||||
## What was asked, in order
|
||||
|
||||
1. **Sedona home into `system.properties`**, with a folder chooser on first run, and a file chooser
|
||||
for the input.
|
||||
2. **Plain paths, not java-escaped**, in that properties file.
|
||||
3. **Timestamped output names** — `[name]-sab2sax-20260728-140609`.
|
||||
4. **The reverse direction**, `.sab` -> `.sax`, direction chosen by the input's extension.
|
||||
5. **A version**, shown in dialog titles.
|
||||
6. **`testprogram/` out of the repo** — later reversed: the test pair is checked in again, while
|
||||
conversion output (`*-sab2sax-*`) stays gitignored.
|
||||
7. **A file chooser in `verify.py`** and a round-trip check on whatever is chosen.
|
||||
8. **A window** with a button per direction, showing the chosen file before converting and the
|
||||
output afterwards.
|
||||
9. **`run.py`** as the entry point; log labels in capitals, values on their own lines.
|
||||
|
||||
## What exists now
|
||||
|
||||
run.py entry point: window with no arguments, CLI with them
|
||||
gui.py the window: a button per direction
|
||||
sab2sax.py the CLI: dispatches on the input extension
|
||||
sab.py sax -> sab encoder
|
||||
sax.py sab -> sax decoder
|
||||
config.py system.properties, dialogs, NAME/VERSION
|
||||
verify.py round trips + comparison against sedonac output
|
||||
system.properties sedona.home
|
||||
testprogram/ test apps verify.py runs against
|
||||
|
||||
`sedona.home` lives in `system.properties`. Missing or pointing somewhere without `manifests/` and a
|
||||
folder chooser opens, then the answer is written back. Output files are always written next to the
|
||||
input as `<stem>-sab2sax-<YYYYMMDD-HHMMSS>.<ext>`, so a run never overwrites the previous one.
|
||||
|
||||
## Decisions worth remembering
|
||||
|
||||
**Plain paths in `system.properties`.** Java's `Properties` (what sedonac reads it with) treats `\`
|
||||
as an escape and doubles them up; we only read the file ourselves, so we write plain Windows paths.
|
||||
The parser collapses `\\` to `\` and keeps every other backslash literal — both forms load, and
|
||||
`C:\niagara` never becomes a newline.
|
||||
|
||||
**The decoder is a port, not a guess.** `sax.py` follows `OfflineApp.decodeAppBinary` +
|
||||
`encodeAppXml` and `OfflineComponent`/`OfflineLink` from
|
||||
`sedonaSrcOrginal/sedona/src/sedona/offline/`. The tree is rebuilt from the
|
||||
`firstChild`/`nextSibling` id chain; the XML is written the way `XWriter` does it — two-space
|
||||
indent, a `<!-- /path -->` comment above every `<comp>`, `&#x..;` escaping.
|
||||
|
||||
**Java float formatting.** `Value.encodeString` for a float is `java.lang.Float.toString`: shortest
|
||||
digits that round-trip, always a decimal point, `E` form outside `[1e-3, 1e7)`. `sax.java_float`
|
||||
reproduces it. It matches modern Java rather than the old JDK's extra-digit quirk on subnormals
|
||||
(`1.4E-45` vs our `1.0E-45`) — only reachable with denormal floats.
|
||||
|
||||
**The manifest default is a 32-bit float too.** A prop equal to its default is not written to the
|
||||
`.sax`. Comparing a decoded `float` against a default parsed as a Python double makes `0.1` look
|
||||
different from `0.1`, and every `minDelta="0.1"` gets written where sedonac omits it. Rounding the
|
||||
default to `f4` first was the last diff between "close" and "identical".
|
||||
|
||||
**A `.sab` cannot give back runtime props.** Only config props are in the binary. sedonac has the
|
||||
same hole. That is why a `.sax` round trip compares the two *binaries*, not the two texts.
|
||||
|
||||
## Verification
|
||||
|
||||
- `test_normal.sax` -> sab, byte-identical to `test_normal_sedonac.sab` (7852 bytes)
|
||||
- `test_normal_sedonac.sab` -> sax, byte-identical to `test_normal.sax` (61294 bytes)
|
||||
- both round trips clean: `sab -> sax -> sab`, and `sax -> sab -> sax -> sab`
|
||||
|
||||
A live `sedonac.exe` comparison was attempted and could not run here — it fails with `Cannot load
|
||||
library: C:\Program Files\Zulu\zulu-21-jre\bin\server\jvm.dll`. `verify.check_encode(sax)` /
|
||||
`check_decode(sab)` with no reference argument still do that comparison on a machine with a JRE
|
||||
sedonac will start under.
|
||||
|
||||
Malformed input fails with one line and exit 1, not a traceback: `comp 0 app: missing child 254`.
|
||||
|
||||
## Where to pick up
|
||||
|
||||
- Untested paths are unchanged from before: action overrides, `Buf`-typed props, non-ASCII strings,
|
||||
and apps whose ids exceed the 256-entry lookup table.
|
||||
- `verify.py` prints `SKIP` and exits 0 when `testprogram/` is empty rather than failing; the test
|
||||
pair is checked in, so it normally runs.
|
||||
- Version lives in `config.py` (`NAME`, `VERSION`). The window title, every dialog title and the CLI
|
||||
banner all read from it.
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Configuration: sedona home lives in `system.properties` next to this file.
|
||||
|
||||
Same key sedonac itself uses, but written as a plain Windows path:
|
||||
|
||||
sedona.home=C:\\path\\to\\sedona
|
||||
|
||||
We only *read* this file, so there is no reason to escape anything. Java's
|
||||
Properties (what sedonac uses) treats `\\` as an escape, so its own files double
|
||||
them up -- `\\\\path\\\\to`. Both parse here: `\\\\` collapses to `\\`, and every
|
||||
other backslash is kept literally, so `C:\\niagara` does not turn into a newline.
|
||||
|
||||
If the key is missing, or the directory no longer holds `manifests/`, a folder
|
||||
chooser is shown and the answer is written back to `system.properties`.
|
||||
"""
|
||||
import os, sys
|
||||
|
||||
NAME = "SAB - SAX Sedona Files Converter"
|
||||
VERSION = "0.0.0.002"
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
PROPS = os.path.join(HERE, "system.properties")
|
||||
KEY = "sedona.home"
|
||||
|
||||
|
||||
def title(text):
|
||||
"""Every dialog carries the program name and version in its title bar."""
|
||||
return "%s %s - %s" % (NAME, VERSION, text)
|
||||
|
||||
|
||||
# ---------- .properties ----------
|
||||
def _unescape(v):
|
||||
"""Collapse `\\\\` to `\\`; leave any other backslash alone.
|
||||
|
||||
Accepts both our plain paths and java-escaped ones, and never mistakes a
|
||||
path segment for an escape (`C:\\niagara` stays `C:\\niagara`).
|
||||
"""
|
||||
out, i = [], 0
|
||||
while i < len(v):
|
||||
if v[i] == "\\" and i + 1 < len(v) and v[i + 1] == "\\":
|
||||
i += 1
|
||||
out.append(v[i])
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def read_props(path=PROPS):
|
||||
props = {}
|
||||
if not os.path.exists(path):
|
||||
return props
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line[0] in "#!" or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
props[k.strip()] = _unescape(v.strip()) # last wins
|
||||
return props
|
||||
|
||||
|
||||
def write_prop(key, value, path=PROPS):
|
||||
"""Set `key`, keeping every other line of the file intact."""
|
||||
lines = []
|
||||
if os.path.exists(path):
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.read().splitlines()
|
||||
|
||||
new = "%s=%s" % (key, value)
|
||||
done = False
|
||||
for i, line in enumerate(lines):
|
||||
s = line.strip()
|
||||
if s and s[0] not in "#!" and "=" in s and s.split("=", 1)[0].strip() == key:
|
||||
lines[i] = new if not done else "#" + line # comment out later dupes
|
||||
done = True
|
||||
if not done:
|
||||
lines.append(new)
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
# ---------- dialogs ----------
|
||||
def _tk():
|
||||
"""A hidden root window, or None when no GUI is available."""
|
||||
try:
|
||||
import tkinter as tk
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
return root
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def ask_directory(text, initial=None):
|
||||
root = _tk()
|
||||
if root is None:
|
||||
return None
|
||||
try:
|
||||
from tkinter import filedialog
|
||||
return filedialog.askdirectory(title=title(text), initialdir=initial or HERE) or None
|
||||
finally:
|
||||
root.destroy()
|
||||
|
||||
|
||||
def ask_open_file(text, filetypes, initial=None):
|
||||
root = _tk()
|
||||
if root is None:
|
||||
return None
|
||||
try:
|
||||
from tkinter import filedialog
|
||||
return filedialog.askopenfilename(title=title(text), filetypes=filetypes,
|
||||
initialdir=initial or HERE) or None
|
||||
finally:
|
||||
root.destroy()
|
||||
|
||||
|
||||
def ask_save_file(text, filetypes, defaultextension, initialfile=None, initial=None):
|
||||
root = _tk()
|
||||
if root is None:
|
||||
return None
|
||||
try:
|
||||
from tkinter import filedialog
|
||||
return filedialog.asksaveasfilename(title=title(text), filetypes=filetypes,
|
||||
defaultextension=defaultextension,
|
||||
initialfile=initialfile,
|
||||
initialdir=initial or HERE) or None
|
||||
finally:
|
||||
root.destroy()
|
||||
|
||||
|
||||
# ---------- api ----------
|
||||
def is_sedona_home(path):
|
||||
return bool(path) and os.path.isdir(os.path.join(path, "manifests"))
|
||||
|
||||
|
||||
def get_sedona_home(interactive=True):
|
||||
"""Path from system.properties, prompting (and saving) when unusable."""
|
||||
home = read_props().get(KEY)
|
||||
if is_sedona_home(home):
|
||||
return home
|
||||
|
||||
if not interactive:
|
||||
raise SystemExit("%s not set (or has no manifests/) in %s" % (KEY, PROPS))
|
||||
|
||||
why = "not set in" if not home else "invalid (%s), update" % home
|
||||
print("sedona.home %s %s -- please choose the sedona installation folder" % (why, PROPS))
|
||||
while True:
|
||||
picked = ask_directory("Select Sedona home (the folder containing manifests/)",
|
||||
home if home and os.path.isdir(home) else None)
|
||||
if not picked:
|
||||
raise SystemExit("no sedona home selected")
|
||||
picked = os.path.normpath(picked)
|
||||
if is_sedona_home(picked):
|
||||
write_prop(KEY, picked)
|
||||
print("saved %s=%s to %s" % (KEY, picked, PROPS))
|
||||
return picked
|
||||
print(" %s has no manifests/ subfolder -- try again" % picked)
|
||||
|
||||
|
||||
SAX = ("Sedona app XML", "*.sax")
|
||||
SAB = ("Sedona app binary", "*.sab")
|
||||
ANY = ("All files", "*.*")
|
||||
|
||||
|
||||
def choose_input_file(initial=None):
|
||||
"""Pick the file to convert -- direction follows its extension."""
|
||||
return ask_open_file("Select the .sax or .sab application file to convert",
|
||||
[("Sedona app (*.sax, *.sab)", "*.sax *.sab"), SAX, SAB, ANY],
|
||||
initial)
|
||||
|
||||
|
||||
def choose_sax_file(initial=None):
|
||||
"""Open a .sax -- the input of a sax -> sab conversion."""
|
||||
return ask_open_file("Select the .sax application file to convert to .sab",
|
||||
[SAX, ANY], initial)
|
||||
|
||||
|
||||
def choose_sab_input(initial=None):
|
||||
"""Open a .sab -- the input of a sab -> sax conversion."""
|
||||
return ask_open_file("Select the .sab application file to convert to .sax",
|
||||
[SAB, ANY], initial)
|
||||
|
||||
|
||||
def choose_sab_file(initialfile=None, initial=None):
|
||||
"""Save-as for the .sab produced from a .sax."""
|
||||
return ask_save_file("Save the .sab application file", [SAB, ANY],
|
||||
".sab", initialfile, initial)
|
||||
|
||||
|
||||
def choose_sax_output(initialfile=None, initial=None):
|
||||
"""Save-as for the .sax produced from a .sab."""
|
||||
return ask_save_file("Save the .sax application file", [SAX, ANY],
|
||||
".sax", initialfile, initial)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(get_sedona_home())
|
||||
@@ -0,0 +1,166 @@
|
||||
"""A window with two buttons, one per direction.
|
||||
|
||||
python gui.py
|
||||
|
||||
Pick a direction, pick a file; the log shows what was chosen before the
|
||||
conversion runs and where the result landed afterwards. Output goes next to the
|
||||
input under a timestamped name, so nothing is ever overwritten.
|
||||
"""
|
||||
import os, sys, traceback
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
|
||||
import config, sab, sax
|
||||
|
||||
|
||||
class App:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
root.title("%s %s" % (config.NAME, config.VERSION))
|
||||
root.minsize(620, 340)
|
||||
|
||||
frame = ttk.Frame(root, padding=12)
|
||||
frame.pack(fill="both", expand=True)
|
||||
frame.columnconfigure(0, weight=1)
|
||||
frame.rowconfigure(3, weight=1)
|
||||
|
||||
# sedona home
|
||||
home_box = ttk.LabelFrame(frame, text="Sedona home", padding=(8, 4))
|
||||
home_box.grid(row=0, column=0, sticky="ew")
|
||||
home_box.columnconfigure(0, weight=1)
|
||||
self.home_var = tk.StringVar(value="(not set)")
|
||||
ttk.Label(home_box, textvariable=self.home_var).grid(row=0, column=0, sticky="w")
|
||||
ttk.Button(home_box, text="Change...", command=self.change_home).grid(row=0, column=1, padx=(8, 0))
|
||||
|
||||
# the two directions
|
||||
buttons = ttk.Frame(frame, padding=(0, 12))
|
||||
buttons.grid(row=1, column=0, sticky="ew")
|
||||
buttons.columnconfigure(0, weight=1)
|
||||
buttons.columnconfigure(1, weight=1)
|
||||
self.b_sab2sax = ttk.Button(buttons, text="SAB file to SAX file", padding=10,
|
||||
command=lambda: self.run("sab"))
|
||||
self.b_sab2sax.grid(row=0, column=0, sticky="ew", padx=(0, 6))
|
||||
self.b_sax2sab = ttk.Button(buttons, text="SAX file to SAB file", padding=10,
|
||||
command=lambda: self.run("sax"))
|
||||
self.b_sax2sab.grid(row=0, column=1, sticky="ew", padx=(6, 0))
|
||||
|
||||
self.status = tk.StringVar(value="Choose a direction.")
|
||||
ttk.Label(frame, textvariable=self.status).grid(row=2, column=0, sticky="w")
|
||||
|
||||
# log
|
||||
log_box = ttk.Frame(frame)
|
||||
log_box.grid(row=3, column=0, sticky="nsew", pady=(8, 0))
|
||||
log_box.columnconfigure(0, weight=1)
|
||||
log_box.rowconfigure(0, weight=1)
|
||||
self.log_text = tk.Text(log_box, height=10, wrap="word", state="disabled")
|
||||
self.log_text.grid(row=0, column=0, sticky="nsew")
|
||||
bar = ttk.Scrollbar(log_box, orient="vertical", command=self.log_text.yview)
|
||||
bar.grid(row=0, column=1, sticky="ns")
|
||||
self.log_text.configure(yscrollcommand=bar.set)
|
||||
|
||||
self.home = None
|
||||
root.after(50, self.first_home)
|
||||
|
||||
# ---------- helpers ----------
|
||||
def log(self, line=""):
|
||||
self.log_text.configure(state="normal")
|
||||
self.log_text.insert("end", line + "\n")
|
||||
self.log_text.see("end")
|
||||
self.log_text.configure(state="disabled")
|
||||
self.root.update_idletasks() # so it is on screen before the work starts
|
||||
|
||||
def set_home(self, path):
|
||||
self.home = path
|
||||
self.home_var.set(path or "(not set)")
|
||||
|
||||
def first_home(self):
|
||||
"""Resolve sedona home at startup; the folder chooser opens if it isn't set."""
|
||||
try:
|
||||
self.set_home(config.get_sedona_home())
|
||||
self.log("sedona home: %s" % self.home)
|
||||
except SystemExit as e:
|
||||
self.log("no sedona home: %s" % e)
|
||||
messagebox.showwarning(config.title("Sedona home"),
|
||||
"No sedona home selected.\n\n"
|
||||
"Set it with Change... before converting.")
|
||||
|
||||
def change_home(self):
|
||||
picked = config.ask_directory("Select Sedona home (the folder containing manifests/)",
|
||||
self.home)
|
||||
if not picked:
|
||||
return
|
||||
picked = os.path.normpath(picked)
|
||||
if not config.is_sedona_home(picked):
|
||||
messagebox.showerror(config.title("Sedona home"),
|
||||
"%s has no manifests/ subfolder." % picked)
|
||||
return
|
||||
config.write_prop(config.KEY, picked)
|
||||
self.set_home(picked)
|
||||
self.log("sedona home: %s (saved to %s)" % (picked, config.PROPS))
|
||||
|
||||
# ---------- conversion ----------
|
||||
def run(self, direction):
|
||||
"""direction: 'sab' means sab -> sax, 'sax' means sax -> sab."""
|
||||
if not config.is_sedona_home(self.home):
|
||||
messagebox.showerror(config.title("Sedona home"),
|
||||
"Set a valid sedona home first (Change...).")
|
||||
return
|
||||
|
||||
src = config.choose_sab_input() if direction == "sab" else config.choose_sax_file()
|
||||
if not src:
|
||||
self.status.set("Cancelled.")
|
||||
return
|
||||
src = os.path.abspath(src)
|
||||
|
||||
# show what was chosen, before doing anything with it
|
||||
self.log()
|
||||
self.log("%s -> %s" % (direction, "sax" if direction == "sab" else "sab"))
|
||||
self.log(" INPUT FILE:")
|
||||
self.log(" %s" % os.path.basename(src))
|
||||
self.log(" INPUT PATH:")
|
||||
self.log(" %s" % os.path.dirname(src))
|
||||
self.status.set("Converting %s ..." % os.path.basename(src))
|
||||
|
||||
if direction == "sab":
|
||||
out = os.path.join(os.path.dirname(src), sax.default_sax_name(src))
|
||||
work = lambda: sax.decode(src, self.home, out)
|
||||
else:
|
||||
out = os.path.join(os.path.dirname(src), sab.default_sab_name(src))
|
||||
work = lambda: sab.encode(src, self.home, out)
|
||||
|
||||
self.busy(True)
|
||||
try:
|
||||
n = work()
|
||||
except Exception as e:
|
||||
self.busy(False)
|
||||
self.log(" FAILED: %s" % e)
|
||||
self.status.set("Failed.")
|
||||
traceback.print_exc()
|
||||
messagebox.showerror(config.title("Conversion failed"), str(e))
|
||||
return
|
||||
self.busy(False)
|
||||
|
||||
self.log(" OUTPUT FILE:")
|
||||
self.log(" %s" % os.path.basename(out))
|
||||
self.log(" OUTPUT PATH:")
|
||||
self.log(" %s" % os.path.dirname(out))
|
||||
self.log(" wrote %d bytes" % n)
|
||||
self.status.set("Done: %s" % os.path.basename(out))
|
||||
|
||||
def busy(self, on):
|
||||
state = "disabled" if on else "normal"
|
||||
self.b_sab2sax.configure(state=state)
|
||||
self.b_sax2sab.configure(state=state)
|
||||
self.root.configure(cursor="watch" if on else "")
|
||||
self.root.update_idletasks()
|
||||
|
||||
|
||||
def main():
|
||||
root = tk.Tk()
|
||||
App(root)
|
||||
root.mainloop()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Start here.
|
||||
|
||||
python run.py the window
|
||||
pythonw run.py the window, without a console behind it
|
||||
python run.py app.sab [out] straight to the command line converter
|
||||
|
||||
No arguments means the GUI; any argument is passed through to sab2sax.py, so
|
||||
the same entry point serves a double-click and a script.
|
||||
"""
|
||||
import sys
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) > 1:
|
||||
import sab2sax
|
||||
return sab2sax.main(argv) or 0
|
||||
import gui
|
||||
return gui.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Minimal pure-Python SAX -> SAB encoder, ported from sedona.offline.OfflineApp."""
|
||||
import os, struct, sys, base64
|
||||
import os, struct, sys, base64, datetime
|
||||
import xml.etree.ElementTree as ET
|
||||
import config
|
||||
|
||||
class Buf:
|
||||
def __init__(self): self.b = bytearray()
|
||||
@@ -13,14 +14,9 @@ class Buf:
|
||||
def str_(self, s): self.b += s.encode("ascii"); self.u1(0)
|
||||
|
||||
# ---------- schema ----------
|
||||
def load_schema(sax_root, home):
|
||||
parts = []
|
||||
for k in sax_root.find("schema").findall("kit"):
|
||||
parts.append((k.get("name"), k.get("checksum")))
|
||||
# sortKits: sys first, rest alphabetical
|
||||
parts.sort(key=lambda p: ("" if p[0] == "sys" else "\x01" + p[0]))
|
||||
|
||||
kits = [] # [(name, checksum, {typeName: typeElem})]
|
||||
def load_kits(parts, home):
|
||||
"""[(kitName, checksumHexOrNone)] in kit-id order -> (kits, types_by_qname)."""
|
||||
kits = [] # [(name, checksum, [typeElem, ...])]
|
||||
types_by_q = {}
|
||||
for kit_id, (name, cks) in enumerate(parts):
|
||||
d = os.path.join(home, "manifests", name)
|
||||
@@ -35,6 +31,13 @@ def load_schema(sax_root, home):
|
||||
kits.append((name, int(mroot.get("checksum"), 16), tlist))
|
||||
return kits, types_by_q
|
||||
|
||||
|
||||
def load_schema(sax_root, home):
|
||||
parts = [(k.get("name"), k.get("checksum")) for k in sax_root.find("schema").findall("kit")]
|
||||
# sortKits: sys first, rest alphabetical
|
||||
parts.sort(key=lambda p: ("" if p[0] == "sys" else "\x01" + p[0]))
|
||||
return load_kits(parts, home)
|
||||
|
||||
def resolve_slots(qname, types_by_q, cache):
|
||||
"""Flatten base chain -> [(name, type, flags, default)] indexed by slot id."""
|
||||
if qname in cache: return cache[qname]
|
||||
@@ -81,7 +84,19 @@ def encode_value(out, vtype, flags, raw):
|
||||
raise Exception("unhandled slot type " + str(vtype))
|
||||
|
||||
# ---------- main ----------
|
||||
def encode(sax_path, home, sab_path):
|
||||
def default_sab_name(sax_path):
|
||||
"""`app.sax` -> `app-sab2sax-20260728-140609.sab` -- never clobbers an existing .sab."""
|
||||
stem = os.path.splitext(os.path.basename(sax_path))[0]
|
||||
stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
return "%s-sab2sax-%s.sab" % (stem, stamp)
|
||||
|
||||
|
||||
def encode(sax_path, home=None, sab_path=None):
|
||||
if home is None:
|
||||
home = config.get_sedona_home()
|
||||
if sab_path is None:
|
||||
sab_path = os.path.join(os.path.dirname(os.path.abspath(sax_path)),
|
||||
default_sab_name(sax_path))
|
||||
root = ET.parse(sax_path).getroot()
|
||||
kits, types_by_q = load_schema(root, home)
|
||||
kit_id = {k[0]: i for i, k in enumerate(kits)}
|
||||
@@ -162,5 +177,5 @@ def encode(sax_path, home, sab_path):
|
||||
return len(out.b)
|
||||
|
||||
if __name__ == "__main__":
|
||||
n = encode(sys.argv[1], sys.argv[2], sys.argv[3])
|
||||
print("wrote", n, "bytes")
|
||||
import sab2sax # the CLI converts either direction; this is just one half
|
||||
sab2sax.main(sys.argv)
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
"""Entry point: converts either direction, the way `sedonac.exe <file>` does --
|
||||
the input's extension decides.
|
||||
|
||||
python sab2sax.py [in.sax|in.sab] [out]
|
||||
|
||||
.sax in -> .sab out (sab.encode)
|
||||
.sab in -> .sax out (sax.decode)
|
||||
|
||||
Omit the input and a file chooser opens; omit the output and a save dialog opens
|
||||
next to the input, pre-filled with a timestamped name. Sedona home comes from
|
||||
system.properties (see config.py).
|
||||
"""
|
||||
import os, sys
|
||||
import config, sab, sax
|
||||
|
||||
|
||||
def convert(in_path, out_path=None, home=None):
|
||||
"""Convert `in_path` by extension, no dialogs. Returns (out_path, bytes written).
|
||||
|
||||
With no `out_path`, writes a timestamped name next to the input."""
|
||||
ext = os.path.splitext(in_path)[1].lower()
|
||||
if ext not in (".sax", ".sab"):
|
||||
raise Exception("don't know what to do with %s -- expected .sax or .sab" % in_path)
|
||||
if out_path is None:
|
||||
name = sab.default_sab_name(in_path) if ext == ".sax" else sax.default_sax_name(in_path)
|
||||
out_path = os.path.join(os.path.dirname(os.path.abspath(in_path)), name)
|
||||
if ext == ".sax":
|
||||
return out_path, sab.encode(in_path, home, out_path)
|
||||
return out_path, sax.decode(in_path, home, out_path)
|
||||
|
||||
|
||||
def main(argv):
|
||||
print("%s %s" % (config.NAME, config.VERSION))
|
||||
home = config.get_sedona_home()
|
||||
|
||||
in_path = argv[1] if len(argv) > 1 else config.choose_input_file()
|
||||
if not in_path:
|
||||
raise SystemExit("no input file selected")
|
||||
in_path = os.path.abspath(in_path)
|
||||
if not os.path.isfile(in_path):
|
||||
raise SystemExit("no such file: " + in_path)
|
||||
|
||||
ext = os.path.splitext(in_path)[1].lower()
|
||||
if ext not in (".sax", ".sab"):
|
||||
raise SystemExit("don't know what to do with %s -- expected .sax or .sab" % in_path)
|
||||
|
||||
if len(argv) > 2:
|
||||
out_path = argv[2]
|
||||
elif ext == ".sax":
|
||||
out_path = config.choose_sab_file(initialfile=sab.default_sab_name(in_path),
|
||||
initial=os.path.dirname(in_path))
|
||||
else:
|
||||
out_path = config.choose_sax_output(initialfile=sax.default_sax_name(in_path),
|
||||
initial=os.path.dirname(in_path))
|
||||
if not out_path:
|
||||
raise SystemExit("no output file selected")
|
||||
|
||||
if ext == ".sax":
|
||||
n = sab.encode(in_path, home, out_path)
|
||||
else:
|
||||
n = sax.decode(in_path, home, out_path)
|
||||
print("wrote %d bytes to %s" % (n, out_path))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv)
|
||||
@@ -0,0 +1,352 @@
|
||||
"""The other direction: .sab -> .sax, ported from sedona.offline.OfflineApp
|
||||
(decodeAppBinary + encodeAppXml) and OfflineComponent/OfflineLink.
|
||||
|
||||
Output is meant to match `sedonac.exe <file.sab>` byte for byte, down to the
|
||||
`<!-- /path -->` comments and Java's float formatting.
|
||||
|
||||
Note what a .sab simply does not contain: only *config* props are stored, so
|
||||
runtime prop values cannot come back. sedonac has the same hole -- both write
|
||||
the manifest default for them, and a value equal to its default is not written
|
||||
at all.
|
||||
"""
|
||||
import os, struct, base64, math, datetime
|
||||
import sab, config
|
||||
|
||||
|
||||
class Reader:
|
||||
def __init__(self, data):
|
||||
self.b, self.p = data, 0
|
||||
|
||||
def take(self, n):
|
||||
if self.p + n > len(self.b):
|
||||
raise Exception("truncated sab at offset %d" % self.p)
|
||||
v = self.b[self.p:self.p + n]
|
||||
self.p += n
|
||||
return v
|
||||
|
||||
def u1(self): return self.take(1)[0]
|
||||
def u2(self): return struct.unpack(">H", self.take(2))[0]
|
||||
def i4(self): return struct.unpack(">i", self.take(4))[0]
|
||||
def i8(self): return struct.unpack(">q", self.take(8))[0]
|
||||
def f4(self): return struct.unpack(">f", self.take(4))[0]
|
||||
def f8(self): return struct.unpack(">d", self.take(8))[0]
|
||||
|
||||
def str_(self):
|
||||
end = self.b.index(0, self.p)
|
||||
s = self.b[self.p:end].decode("ascii")
|
||||
self.p = end + 1
|
||||
return s
|
||||
|
||||
|
||||
# ---------- values ----------
|
||||
def decode_value(r, vtype, flags):
|
||||
if "s" in flags: # asStr Buf: u2 len (incl NUL)
|
||||
n = r.u2()
|
||||
return r.take(n).split(b"\0")[0].decode("ascii")
|
||||
if vtype == "bool":
|
||||
v = r.u1()
|
||||
return None if v == 2 else (v == 1)
|
||||
if vtype == "byte": return r.u1()
|
||||
if vtype == "short": return r.u2()
|
||||
if vtype == "int": return r.i4()
|
||||
if vtype == "long": return r.i8()
|
||||
if vtype == "float": return r.f4()
|
||||
if vtype == "double": return r.f8()
|
||||
if vtype in ("sys::Buf", "buf"):
|
||||
return bytes(r.take(r.u2()))
|
||||
raise Exception("unhandled slot type " + str(vtype))
|
||||
|
||||
|
||||
def default_value(vtype, flags, raw):
|
||||
"""The manifest `default`, or Value.defaultForType -> zero. Same rules as sab.py."""
|
||||
if "s" in flags:
|
||||
return raw or ""
|
||||
if vtype == "bool":
|
||||
return None if raw == "null" else raw in ("true", "1")
|
||||
if vtype in ("byte", "short", "int", "long"):
|
||||
return sab._int(raw)
|
||||
if vtype == "float":
|
||||
# the default is a 32-bit Float too, so round before comparing:
|
||||
# 0.1 as a double is not the 0.1 that came out of the file
|
||||
return to_f32(sab._flt(raw))
|
||||
if vtype == "double":
|
||||
return sab._flt(raw)
|
||||
if vtype in ("sys::Buf", "buf"):
|
||||
return base64.b64decode(raw) if raw else b""
|
||||
raise Exception("unhandled slot type " + str(vtype))
|
||||
|
||||
|
||||
def same(a, b):
|
||||
if isinstance(a, float) and isinstance(b, float):
|
||||
return (math.isnan(a) and math.isnan(b)) or a == b
|
||||
return type(a) is type(b) and a == b
|
||||
|
||||
|
||||
# ---------- java number formatting ----------
|
||||
F32_MAX = 3.4028234663852886e38 # largest finite float
|
||||
F32_INF_EDGE = 3.4028235677973366e38 # midpoint to 2**128: below rounds to F32_MAX
|
||||
|
||||
|
||||
def to_f32(v):
|
||||
"""Round a double to the nearest float. struct raises past F32_MAX instead
|
||||
of rounding, so handle that edge ourselves."""
|
||||
try:
|
||||
return struct.unpack(">f", struct.pack(">f", v))[0]
|
||||
except OverflowError:
|
||||
if abs(v) < F32_INF_EDGE:
|
||||
return math.copysign(F32_MAX, v)
|
||||
return math.copysign(math.inf, v)
|
||||
|
||||
|
||||
def _shortest(a, single):
|
||||
"""Fewest digits that still round-trip -- what Java's Float/Double.toString picks."""
|
||||
for prec in range(0, 17):
|
||||
s = "%.*e" % (prec, a)
|
||||
v = float(s)
|
||||
if single:
|
||||
v = to_f32(v)
|
||||
if v == a:
|
||||
break
|
||||
mant, exp = s.split("e")
|
||||
digits = mant.replace(".", "").rstrip("0") or "0"
|
||||
return digits, int(exp)
|
||||
|
||||
|
||||
def java_float(v, single=True):
|
||||
"""Java Float.toString / Double.toString: always a decimal point, E-form
|
||||
outside [1e-3, 1e7)."""
|
||||
if math.isnan(v):
|
||||
return "NaN"
|
||||
if math.isinf(v):
|
||||
return "-Infinity" if v < 0 else "Infinity"
|
||||
neg = math.copysign(1.0, v) < 0
|
||||
a = abs(v)
|
||||
if a == 0.0:
|
||||
return "-0.0" if neg else "0.0"
|
||||
|
||||
digits, exp = _shortest(a, single)
|
||||
if -3 <= exp < 7:
|
||||
if exp >= 0:
|
||||
head = digits[:exp + 1].ljust(exp + 1, "0")
|
||||
tail = digits[exp + 1:] or "0"
|
||||
else:
|
||||
head, tail = "0", "0" * (-exp - 1) + digits
|
||||
out = head + "." + tail
|
||||
else:
|
||||
out = digits[0] + "." + (digits[1:] or "0") + "E" + str(exp)
|
||||
return "-" + out if neg else out
|
||||
|
||||
|
||||
def encode_string(val, vtype, flags):
|
||||
"""Value.encodeString()."""
|
||||
if "s" in flags:
|
||||
return _literal(val)
|
||||
if vtype == "bool":
|
||||
return "null" if val is None else ("true" if val else "false")
|
||||
if vtype == "float":
|
||||
return "null" if math.isnan(val) else java_float(val, True)
|
||||
if vtype == "double":
|
||||
return "null" if math.isnan(val) else java_float(val, False)
|
||||
if vtype in ("sys::Buf", "buf"):
|
||||
return base64.b64encode(val).decode("ascii")
|
||||
return str(val)
|
||||
|
||||
|
||||
def _literal(s):
|
||||
"""TextUtil.toLiteral."""
|
||||
out = []
|
||||
for c in s:
|
||||
out.append({"\0": "\\0", "\n": "\\n", "\r": "\\r", "\t": "\\t",
|
||||
"\\": "\\\\", '"': '\\"', "$": "\\$"}.get(c, c))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _safe(s):
|
||||
"""XWriter.safe: escape markup, quotes, and anything outside printable ASCII."""
|
||||
out = []
|
||||
for c in s:
|
||||
o = ord(c)
|
||||
if o < 0x20 or o > 0x7e or c in "'\"":
|
||||
out.append("&#x%x;" % o)
|
||||
elif c == "<":
|
||||
out.append("<")
|
||||
elif c == ">":
|
||||
out.append(">")
|
||||
elif c == "&":
|
||||
out.append("&")
|
||||
else:
|
||||
out.append(c)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
# ---------- binary decode ----------
|
||||
def read_app(sab_path, home):
|
||||
"""-> (kits, comps by id, links). Each comp: id/type/name/parent/kids/props."""
|
||||
r = Reader(open(sab_path, "rb").read())
|
||||
if r.i4() != 0x73617070:
|
||||
raise Exception("%s is not a .sab (bad magic)" % sab_path)
|
||||
ver = r.i4()
|
||||
if ver != 0x0003:
|
||||
raise Exception("unsupported sab version 0x%x" % ver)
|
||||
|
||||
parts = []
|
||||
for _ in range(r.u1()):
|
||||
name = r.str_()
|
||||
parts.append((name, "%08x" % (r.i4() & 0xFFFFFFFF)))
|
||||
kits, types_by_q = sab.load_kits(parts, home)
|
||||
r.u2() # maxId, recomputed on the way out
|
||||
|
||||
qname_of = {}
|
||||
for ki, (kname, _, tlist) in enumerate(kits):
|
||||
for t in tlist:
|
||||
qname_of[(ki, int(t.get("id")))] = "%s::%s" % (kname, t.get("name"))
|
||||
|
||||
cache = {}
|
||||
by_id, order = {}, []
|
||||
while True:
|
||||
cid = r.u2()
|
||||
if cid == 0xFFFF:
|
||||
break
|
||||
ki, ti = r.u1(), r.u1()
|
||||
if (ki, ti) not in qname_of:
|
||||
raise Exception("comp %d: no type %d in kit %d" % (cid, ti, ki))
|
||||
qname = qname_of[(ki, ti)]
|
||||
c = {"id": cid, "type": qname, "name": r.str_(), "kids": [], "props": []}
|
||||
c["parentId"], c["childId"], c["siblingId"] = r.u2(), r.u2(), r.u2()
|
||||
for name, vtype, flags, dflt in sab.resolve_slots(qname, types_by_q, cache):
|
||||
if "a" in flags or "c" not in flags:
|
||||
continue # config props only, in slot-id order
|
||||
c["props"].append((name, vtype, flags, dflt, decode_value(r, vtype, flags)))
|
||||
if r.u1() != ord(";"):
|
||||
raise Exception("corrupted component %d %s" % (cid, c["name"]))
|
||||
if cid in by_id:
|
||||
raise Exception("duplicate id: %d" % cid)
|
||||
by_id[cid] = c
|
||||
order.append(cid)
|
||||
|
||||
# rebuild the tree from the child/sibling id chain
|
||||
def must(cid, what, owner):
|
||||
if cid not in by_id:
|
||||
raise Exception("comp %d %s: missing %s %d" % (owner["id"], owner["name"], what, cid))
|
||||
return by_id[cid]
|
||||
|
||||
for c in by_id.values():
|
||||
c["parent"] = must(c["parentId"], "parent", c) if c["parentId"] != 0xFFFF else None
|
||||
if c["childId"] != 0xFFFF:
|
||||
kid = must(c["childId"], "child", c)
|
||||
while True:
|
||||
c["kids"].append(kid)
|
||||
if kid["siblingId"] == 0xFFFF:
|
||||
break
|
||||
kid = must(kid["siblingId"], "nextSibling", kid)
|
||||
|
||||
links = []
|
||||
while True:
|
||||
fc = r.u2()
|
||||
if fc == 0xFFFF:
|
||||
break
|
||||
fs, tc, ts = r.u1(), r.u2(), r.u1()
|
||||
for cid in (fc, tc):
|
||||
if cid not in by_id:
|
||||
raise Exception("link references unknown comp id %d" % cid)
|
||||
links.append((by_id[fc], fs, by_id[tc], ts))
|
||||
if r.u1() != ord("."):
|
||||
raise Exception("invalid app end marker")
|
||||
|
||||
return kits, by_id, order, links, types_by_q, cache
|
||||
|
||||
|
||||
# ---------- xml encode ----------
|
||||
def path_of(c):
|
||||
parts = []
|
||||
while c["parent"] is not None:
|
||||
parts.append(c["name"])
|
||||
c = c["parent"]
|
||||
return "/" + "/".join(reversed(parts)) if parts else "/"
|
||||
|
||||
|
||||
def write_xml(out, kits, app, links, types_by_q, cache):
|
||||
w = out.append
|
||||
w("<?xml version='1.0'?>\n")
|
||||
w("<sedonaApp>\n")
|
||||
w("<schema>\n")
|
||||
for name, cks, _ in kits:
|
||||
w(' <kit name="%s" checksum="%08x" />\n' % (_safe(name), cks & 0xFFFFFFFF))
|
||||
w("</schema>\n")
|
||||
|
||||
w("<app>\n")
|
||||
write_props(w, app, 2)
|
||||
for kid in app["kids"]:
|
||||
write_comp(w, kid, 2)
|
||||
w("</app>\n")
|
||||
|
||||
w("<links>\n")
|
||||
for fc, fs, tc, ts in links:
|
||||
w(' <link from="%s.%s" to="%s.%s"/>\n' % (
|
||||
_safe(path_of(fc)), _safe(slot_name(fc, fs, types_by_q, cache)),
|
||||
_safe(path_of(tc)), _safe(slot_name(tc, ts, types_by_q, cache))))
|
||||
w("</links>\n")
|
||||
w("</sedonaApp>\n")
|
||||
|
||||
|
||||
def slot_name(comp, slot_id, types_by_q, cache):
|
||||
slots = sab.resolve_slots(comp["type"], types_by_q, cache)
|
||||
if slot_id >= len(slots):
|
||||
raise Exception("%s has no slot id %d" % (comp["type"], slot_id))
|
||||
return slots[slot_id][0]
|
||||
|
||||
|
||||
def write_props(w, c, indent):
|
||||
"""Only props whose value differs from the slot default get written."""
|
||||
n = 0
|
||||
for name, vtype, flags, dflt, val in c["props"]:
|
||||
if same(val, default_value(vtype, flags, dflt)):
|
||||
continue
|
||||
w('%s<prop name="%s" val="%s"/>\n' % (
|
||||
" " * indent, _safe(name), _safe(encode_string(val, vtype, flags))))
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def write_comp(w, c, indent):
|
||||
pad = " " * indent
|
||||
w("%s<!-- %s -->\n" % (pad, path_of(c)))
|
||||
w('%s<comp name="%s" id="%d" type="%s"' % (pad, _safe(c["name"]), c["id"], _safe(c["type"])))
|
||||
|
||||
body = []
|
||||
n = write_props(body.append, c, indent + 2)
|
||||
for kid in c["kids"]:
|
||||
write_comp(body.append, kid, indent + 2)
|
||||
if not n and not c["kids"]:
|
||||
w("/>\n")
|
||||
else:
|
||||
w(">\n")
|
||||
w("".join(body))
|
||||
w("%s</comp>\n" % pad)
|
||||
|
||||
|
||||
# ---------- main ----------
|
||||
def default_sax_name(sab_path):
|
||||
"""`app.sab` -> `app-sab2sax-20260728-140609.sax`."""
|
||||
stem = os.path.splitext(os.path.basename(sab_path))[0]
|
||||
stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
return "%s-sab2sax-%s.sax" % (stem, stamp)
|
||||
|
||||
|
||||
def decode(sab_path, home=None, sax_path=None):
|
||||
if home is None:
|
||||
home = config.get_sedona_home()
|
||||
if sax_path is None:
|
||||
sax_path = os.path.join(os.path.dirname(os.path.abspath(sab_path)),
|
||||
default_sax_name(sab_path))
|
||||
|
||||
kits, by_id, order, links, types_by_q, cache = read_app(sab_path, home)
|
||||
if 0 not in by_id:
|
||||
raise Exception("no app component (id 0) in " + sab_path)
|
||||
|
||||
out = []
|
||||
write_xml(out, kits, by_id[0], links, types_by_q, cache)
|
||||
text = "".join(out)
|
||||
with open(sax_path, "w", encoding="ascii", newline="\n") as f:
|
||||
f.write(text)
|
||||
return len(text)
|
||||
@@ -0,0 +1,4 @@
|
||||
# Directory of the Sedona installation (the folder holding manifests/).
|
||||
# Plain path, no escaping needed. If this is missing or wrong, sab.py asks for
|
||||
# it with a folder chooser and writes the answer back here.
|
||||
sedona.home=C:\Users\aa\OneDrive - Ontrol\Documents\_is\NiagaraAXSedona\sedona
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,53 +1,196 @@
|
||||
"""Regression check: our SAB output must be byte-identical to sedonac.exe's.
|
||||
"""Regression checks: our output must be byte-identical to sedonac.exe's, and a
|
||||
conversion must survive a round trip.
|
||||
|
||||
Usage: python verify.py [sedona_home]
|
||||
sax -> sab check_encode()
|
||||
sab -> sax check_decode()
|
||||
sab -> sax -> sab round_trip_sab() bytes must come back identical
|
||||
sax -> sab -> sax -> sab round_trip_sax() the two sabs must be identical
|
||||
|
||||
Usage: python verify.py [app.sax|app.sab] [sedona_home]
|
||||
|
||||
With no file argument a chooser opens; cancel it and the built-in
|
||||
`testprogram/` regression runs instead (if those files are present). Sedona home
|
||||
comes from system.properties (see config.py) unless given.
|
||||
"""
|
||||
import os, sys, subprocess, tempfile, shutil
|
||||
import sab
|
||||
import sab, sax, config
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
HOME = sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.dirname(HERE), "sedona")
|
||||
TESTS = os.path.join(HERE, "testprogram")
|
||||
HOME = None
|
||||
|
||||
|
||||
def check(sax, reference=None):
|
||||
"""Encode `sax` with sab.py; compare against `reference` or a fresh sedonac run."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
if reference is None:
|
||||
work = os.path.join(tmp, os.path.basename(sax))
|
||||
shutil.copy(sax, work)
|
||||
exe = os.path.join(HOME, "bin", "sedonac.exe")
|
||||
r = subprocess.run([exe, work], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
print(" sedonac failed:\n" + r.stdout + r.stderr)
|
||||
return False
|
||||
reference = os.path.splitext(work)[0] + ".sab"
|
||||
def home():
|
||||
"""Resolved once, on first use -- so importing this module opens no dialog."""
|
||||
global HOME
|
||||
if HOME is None:
|
||||
HOME = config.get_sedona_home()
|
||||
return HOME
|
||||
|
||||
mine = os.path.join(tmp, "mine.sab")
|
||||
sab.encode(sax, HOME, mine)
|
||||
|
||||
a = open(reference, "rb").read()
|
||||
b = open(mine, "rb").read()
|
||||
name = os.path.basename(sax)
|
||||
if a == b:
|
||||
print(" PASS %-28s %d bytes identical" % (name, len(a)))
|
||||
return True
|
||||
def sedonac(src, tmp):
|
||||
"""Run sedonac on a copy of `src`; return the file it produced (needs a JRE)."""
|
||||
work = os.path.join(tmp, os.path.basename(src))
|
||||
shutil.copy(src, work)
|
||||
exe = os.path.join(home(), "bin", "sedonac.exe")
|
||||
r = subprocess.run([exe, work], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
print(" sedonac failed:\n" + r.stdout + r.stderr)
|
||||
return None
|
||||
other = ".sax" if work.endswith(".sab") else ".sab"
|
||||
return os.path.splitext(work)[0] + other
|
||||
|
||||
diffs = [i for i in range(min(len(a), len(b))) if a[i] != b[i]]
|
||||
print(" FAIL %-28s sedonac=%d ours=%d, %d byte diffs" % (name, len(a), len(b), len(diffs)))
|
||||
if diffs:
|
||||
i = diffs[0]
|
||||
|
||||
def compare(label, reference, mine, binary, names=("sedonac", "ours")):
|
||||
a = open(reference, "rb").read()
|
||||
b = open(mine, "rb").read()
|
||||
if a == b:
|
||||
print(" PASS %-34s %d bytes identical" % (label, len(a)))
|
||||
return True
|
||||
|
||||
diffs = [i for i in range(min(len(a), len(b))) if a[i] != b[i]]
|
||||
print(" FAIL %-34s %s=%d %s=%d, %d byte diffs" % (
|
||||
label, names[0], len(a), names[1], len(b), len(diffs)))
|
||||
if diffs:
|
||||
i = diffs[0]
|
||||
if binary:
|
||||
lo, hi = max(0, i - 12), i + 12
|
||||
print(" first diff at offset %d" % i)
|
||||
print(" sedonac %s" % a[lo:hi].hex())
|
||||
print(" ours %s" % b[lo:hi].hex())
|
||||
return False
|
||||
print(" %-8s %s" % (names[0], a[lo:hi].hex()))
|
||||
print(" %-8s %s" % (names[1], b[lo:hi].hex()))
|
||||
else:
|
||||
line = a[:i].count(b"\n") + 1
|
||||
print(" first diff at line %d" % line)
|
||||
print(" %-8s %s" % (names[0], a.splitlines()[line - 1]))
|
||||
print(" %-8s %s" % (names[1], b.splitlines()[line - 1]))
|
||||
return False
|
||||
|
||||
|
||||
# ---------- against sedonac ----------
|
||||
def check_encode(sax_file, reference=None):
|
||||
"""sax -> sab, against `reference` or a fresh sedonac run."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
reference = reference or sedonac(sax_file, tmp)
|
||||
if reference is None:
|
||||
return False
|
||||
mine = os.path.join(tmp, "mine.sab")
|
||||
sab.encode(sax_file, home(), mine)
|
||||
return compare(os.path.basename(sax_file) + " -> sab", reference, mine, True)
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def check_decode(sab_file, reference=None):
|
||||
"""sab -> sax, against `reference` or a fresh sedonac run."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
reference = reference or sedonac(sab_file, tmp)
|
||||
if reference is None:
|
||||
return False
|
||||
mine = os.path.join(tmp, "mine.sax")
|
||||
sax.decode(sab_file, home(), mine)
|
||||
return compare(os.path.basename(sab_file) + " -> sax", reference, mine, False)
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
# ---------- round trips ----------
|
||||
def round_trip_sab(sab_file):
|
||||
"""sab -> sax -> sab must come back to the same bytes."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
mid = os.path.join(tmp, "mid.sax")
|
||||
back = os.path.join(tmp, "back.sab")
|
||||
sax.decode(sab_file, home(), mid)
|
||||
sab.encode(mid, home(), back)
|
||||
return compare(os.path.basename(sab_file) + " sab->sax->sab",
|
||||
sab_file, back, True, ("original", "ours"))
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def round_trip_sax(sax_file):
|
||||
"""sax -> sab -> sax -> sab: the two sabs must match.
|
||||
|
||||
The sax leg is not compared against the original file, because a .sab holds
|
||||
no runtime prop values and no formatting -- coming back through it is lossy
|
||||
on purpose. The binary is the thing that has to be stable, so we compare
|
||||
that. Any difference from the original sax is printed as a note, not a
|
||||
failure."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
try:
|
||||
first = os.path.join(tmp, "first.sab")
|
||||
mid = os.path.join(tmp, "mid.sax")
|
||||
second = os.path.join(tmp, "second.sab")
|
||||
sab.encode(sax_file, home(), first)
|
||||
sax.decode(first, home(), mid)
|
||||
sab.encode(mid, home(), second)
|
||||
|
||||
ok = compare(os.path.basename(sax_file) + " sax->sab->sax->sab",
|
||||
first, second, True, ("1st sab", "2nd sab"))
|
||||
|
||||
a = open(sax_file, "rb").read()
|
||||
b = open(mid, "rb").read()
|
||||
if a != b:
|
||||
al, bl = a.splitlines(), b.splitlines()
|
||||
print(" note: the regenerated sax differs from the original "
|
||||
"(%d vs %d lines) -- expected where the original carries runtime "
|
||||
"props or different formatting" % (len(al), len(bl)))
|
||||
return ok
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def check_file(path):
|
||||
"""Round-trip whatever was chosen, by extension."""
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext not in (".sax", ".sab"):
|
||||
print(" don't know what to do with %s -- expected .sax or .sab" % path)
|
||||
return False
|
||||
try:
|
||||
return round_trip_sax(path) if ext == ".sax" else round_trip_sab(path)
|
||||
except Exception as e: # a malformed file is a failure, not a traceback
|
||||
print(" FAIL %-34s %s" % (os.path.basename(path), e))
|
||||
return False
|
||||
|
||||
|
||||
def builtin():
|
||||
"""The checked-in pair, when testprogram/ is populated locally."""
|
||||
saxf = os.path.join(TESTS, "test_normal.sax")
|
||||
sabf = os.path.join(TESTS, "test_normal_sedonac.sab")
|
||||
missing = [f for f in (saxf, sabf) if not os.path.isfile(f)]
|
||||
if missing:
|
||||
# testprogram/ is deliberately not in the repo -- put the pair back to run this
|
||||
print(" SKIP no test files: %s" % ", ".join(os.path.basename(f) for f in missing))
|
||||
return None
|
||||
|
||||
ok = check_encode(saxf, sabf)
|
||||
ok &= check_decode(sabf, saxf)
|
||||
ok &= round_trip_sab(sabf)
|
||||
ok &= round_trip_sax(saxf)
|
||||
return ok
|
||||
|
||||
|
||||
def main(argv):
|
||||
global HOME
|
||||
print("%s %s" % (config.NAME, config.VERSION))
|
||||
path = argv[1] if len(argv) > 1 else config.choose_input_file()
|
||||
if len(argv) > 2:
|
||||
HOME = argv[2]
|
||||
print("sedona home: %s" % home())
|
||||
|
||||
if not path:
|
||||
print("no file chosen -- running the built-in checks")
|
||||
ok = builtin()
|
||||
return 0 if ok is not False else 1
|
||||
|
||||
path = os.path.abspath(path)
|
||||
if not os.path.isfile(path):
|
||||
print("no such file: " + path)
|
||||
return 1
|
||||
return 0 if check_file(path) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("sedona home: %s" % HOME)
|
||||
ok = check(os.path.join(HERE, "test_normal.sax"),
|
||||
os.path.join(HERE, "test_normal_sedonac.sab"))
|
||||
sys.exit(0 if ok else 1)
|
||||
sys.exit(main(sys.argv))
|
||||
|
||||
Reference in New Issue
Block a user