diff --git a/README.md b/README.md index 0ac4e6f..25a257e 100644 --- a/README.md +++ b/README.md @@ -497,6 +497,11 @@ doc = "String buffer length." Named runtime array dimension defaults. - Each entry is a table with positive integer `default` and optional `doc`. +- Optional `source = { namelist = "...", property = "..." }` identifies the + intrinsic integer scalar supplying the dimension during Python/CLI loading. + `property` defaults to the dimension name. References are resolved against + configured schemas case-insensitively; component paths and array elements + are unsupported. - Names must be unique across `[constants]` and `[dimensions]`. - Names must not contain `__`; nml-tools reserves double underscores for generated helper identifiers. @@ -520,6 +525,27 @@ default = 4 doc = "Maximum number of iterations." ``` +With a source property: + +```toml +[dimensions.n_values] +default = 2 +source = { namelist = "settings", property = "count" } +``` + +Loading resolves an explicit caller or `--dimensions` override first, then an +explicit source assignment, then the dimension default. A schema default for +the source property does not supply a dimension. Missing or null-only source +assignments use the dimension default; nulls after an explicit assignment retain +that assignment. Inferred values must be positive and satisfy the source +property's schema constraints. For dimensions without overrides, repeated +source namelist groups are ambiguous even if they supply equal values. + +Sources are inferred before full schema evaluation so runtime-sized arrays use +the actual extents. This metadata does not change generated Fortran readers, +Python wrappers, or generated outputs; generation still uses configured +dimension defaults. + ### [kinds] Defines the kind module and allowed kinds. @@ -674,6 +700,36 @@ namelists = ["run", "physics"] required = ["run"] ``` +### project_profiles (array) + +Optional named sets of file profiles required for a project setup. + +- `name` (string): unique, non-empty project profile name. +- `file_profiles` (non-empty list of strings): existing file profile names. +- `title` / `description` (string, optional): display and documentation metadata. + +Names and references are matched case-insensitively. Unknown references, +duplicate members, and duplicate project names are rejected. Omission or +`project_profiles = []` defines no project profiles. File profiles can be shared +across project profiles, and their `default_file` values remain filename hints. + +```toml +[[project_profiles]] +name = "standard" +title = "Standard simulation" +file_profiles = ["main", "outputs"] + +[[project_profiles]] +name = "minimal" +file_profiles = ["main"] +``` + +All members describe files needed for the selected project setup, each subject +to its existing file-profile validation rules. These profiles are metadata for +future tooling: defining them does not select a project, validate files +together, or change generated outputs. Project profiles impose no dimension +source uniqueness restriction and are independent of dimension source wiring. + ### templates (array) Template namelist output configuration. @@ -846,6 +902,26 @@ array shapes. `--dimensions` supplies runtime array dimensions. Names are matched case-insensitively, normalized to lowercase, and must stay unique across both sets. +Configured dimension sources are read from the target namelist file. Use one +optional `--dim-file PATH` to supply additional sources from another namelist +file: + +```bash +nml-tools validate --config nml-config.toml --profile main \ + --dim-file settings.nml run.nml +``` + +Only the target file undergoes full validation. The dimension file is parsed +completely, but only configured source entries are evaluated; unrelated groups +and missing required properties are not fully validated. Sources are resolved +against all configured schemas, including namelists outside the selected file +profile. Supplying the target itself as `--dim-file` reads it once. + +`--dim-file` requires config, either discovered during config-driven validation +or supplied explicitly with `--config` alongside `--schema`. It accepts one file; +use explicit `--dimensions NAME=INT` values when further sources are needed. +See [the dimension sources example](examples/06_dimension_sources/README.md). + Array assignments use one-based schema bounds and Fortran element order, with the first subscript varying fastest. Scalar subscripts and sections are supported, including omitted bounds and positive or negative strides. Values @@ -861,6 +937,57 @@ Character substring assignment, complex schema values, nested derived values, component arrays, nondefault lower bounds, and user-defined formatted I/O are currently explicit capability boundaries. +## GUI + +Install the current checkout with its optional GUI dependencies and a Qt binding +(Python 3.9 or newer): + +```bash +python -m pip install '.[gui]' PyQt5 +nml-tools gui -i /path/to/schemas -o /path/to/project +``` + +QtPy also permits other supported Qt bindings. Omit `-i` to use the current +directory, and `-o` to save alongside `nml-config.toml`. + +The editor loads each profile's `default_file` from the output directory and +saves directly to that namelist file. Missing values use schema defaults, then +examples or type-specific suggestions. Invalid input is reported. No JSON +configuration or JSON-to-namelist conversion is involved. Selected groups are +rewritten on save; other groups and their comments remain intact. + +Each profile has a tab with a namelist list, editable pages, navigation, reset, +cancel, and save actions. The Config tab applies runtime dimensions with Run; +the `+` tab can import a `.nml` file or create another profile. One-element +one-dimensional arrays use inline scalar or derived-object editors while +remaining arrays in the saved namelist. Resizable arrays retain a resize action. + +Applications can launch a subset of the configured profiles: + +```python +from nml_tools.gui import launch_gui + +launch_gui( + schemas_dir="/path/to/schemas", + output_dir="/path/to/project", + file_profiles={"main": ["mainconfig", "time_periods"], "parameter": []}, + initial_values={"main": {"mainconfig": {"nDomains": 2}}}, + initial_dimensions={"max_domains": 2}, +) +``` + +`file_profiles` is the third argument. `None` or `{}` selects all configured +profiles; an empty list selects every namelist in that profile. Names are +checked case-insensitively and pages retain TOML order. `initial_values` is an +in-memory dictionary of profile/namelist/field values applied over existing +input. Use keyword arguments for values and dimensions when migrating callers +of the previous GUI API. + +Named runtime dimensions come from TOML, `initial_dimensions`, or the Config +controls; partial namelist assignments cannot reliably recover them. Existing +Qt applications reuse their QApplication; importing `nml_tools.gui` alone +does not import Qt. + ## Error handling Generated type-bound procedures return integer status codes and accept an diff --git a/examples/05_file_profiles/README.md b/examples/05_file_profiles/README.md index cf2be56..f5e7da4 100644 --- a/examples/05_file_profiles/README.md +++ b/examples/05_file_profiles/README.md @@ -16,6 +16,11 @@ It groups them into two logical files: - `outputs`, with the `outputs` namelist and an `outputs.nml` default file hint; `outputs` is required +Two `[[project_profiles]]` entries describe project setups: `standard` requires +both file profiles, while `minimal` requires only `main`. They are metadata for +future project tooling; the CLI continues to validate each file separately. +The existing templates and their generated contents remain the same. + Generate the templates with: ```bash diff --git a/examples/05_file_profiles/nml-config.toml b/examples/05_file_profiles/nml-config.toml index fccfb9c..80dc9b4 100644 --- a/examples/05_file_profiles/nml-config.toml +++ b/examples/05_file_profiles/nml-config.toml @@ -31,6 +31,17 @@ default_file = "outputs.nml" namelists = ["outputs"] required = ["outputs"] +[[project_profiles]] +name = "standard" +title = "Standard simulation" +description = "Runtime, physics, and output configuration." +file_profiles = ["main", "outputs"] + +[[project_profiles]] +name = "minimal" +title = "Minimal simulation" +file_profiles = ["main"] + [[templates]] profile = "main" path = "out/run-template.nml" diff --git a/examples/06_dimension_sources/README.md b/examples/06_dimension_sources/README.md new file mode 100644 index 0000000..4877357 --- /dev/null +++ b/examples/06_dimension_sources/README.md @@ -0,0 +1,45 @@ +# Dimension Sources Example + +The config wires the runtime dimension `n_values` to `settings%count`. Its +configured default is two, while the sample settings explicitly supply three. +The source field has a different name from the dimension, avoiding generated +Fortran member name collisions. + +From the repository root, infer the dimension from a combined file. The source +appears after the data array, which still validates against the inferred extent: + +```bash +nml-tools validate --config examples/06_dimension_sources/nml-config.toml \ + examples/06_dimension_sources/combined.nml +``` + +Validate only the data file using the separate settings file as source context: + +```bash +nml-tools validate --config examples/06_dimension_sources/nml-config.toml \ + --profile data --dim-file examples/06_dimension_sources/settings.nml \ + examples/06_dimension_sources/data.nml +``` + +Explicit dimensions take precedence over source values: + +```bash +nml-tools validate --config examples/06_dimension_sources/nml-config.toml \ + --profile data --dim-file examples/06_dimension_sources/settings.nml \ + --dimensions n_values=4 examples/06_dimension_sources/data-override.nml +``` + +The `standard` project profile describes the two file profiles together. It +does not trigger project-wide validation or constrain dimension sources. + +Generation uses the dimension default of two and does not read settings files: + +```bash +nml-tools generate --config examples/06_dimension_sources/nml-config.toml +nml-tools check --config examples/06_dimension_sources/nml-config.toml --diff +nml-tools validate --config examples/06_dimension_sources/nml-config.toml \ + --profile data examples/06_dimension_sources/out/data-template.nml +``` + +The generated template has two values. With no source group present, validation +falls back to the configured dimension default. diff --git a/examples/06_dimension_sources/combined.nml b/examples/06_dimension_sources/combined.nml new file mode 100644 index 0000000..2f46764 --- /dev/null +++ b/examples/06_dimension_sources/combined.nml @@ -0,0 +1,7 @@ +&data + values = 1, 2, 3 +/ + +&settings + count = 3 +/ diff --git a/examples/06_dimension_sources/data-override.nml b/examples/06_dimension_sources/data-override.nml new file mode 100644 index 0000000..8a1f1fc --- /dev/null +++ b/examples/06_dimension_sources/data-override.nml @@ -0,0 +1,3 @@ +&data + values = 1, 2, 3, 4 +/ diff --git a/examples/06_dimension_sources/data.nml b/examples/06_dimension_sources/data.nml new file mode 100644 index 0000000..2e868d1 --- /dev/null +++ b/examples/06_dimension_sources/data.nml @@ -0,0 +1,3 @@ +&data + values = 1, 2, 3 +/ diff --git a/examples/06_dimension_sources/nml-config.toml b/examples/06_dimension_sources/nml-config.toml new file mode 100644 index 0000000..6985a6e --- /dev/null +++ b/examples/06_dimension_sources/nml-config.toml @@ -0,0 +1,38 @@ +[kinds] +module = "iso_fortran_env" + +[dimensions.n_values] +default = 2 +doc = "Number of data values." +source = { namelist = "settings", property = "count" } + +[[namelists]] +schema = "schemas/data.yml" + +[[namelists]] +schema = "schemas/settings.yml" + +[[file_profiles]] +name = "data" +default_file = "data.nml" +namelists = ["data"] +required = ["data"] + +[[file_profiles]] +name = "settings" +default_file = "settings.nml" +namelists = ["settings"] +required = ["settings"] + +[[project_profiles]] +name = "standard" +file_profiles = ["data", "settings"] + +[[templates]] +profile = "data" +path = "out/data-template.nml" +doc_mode = "plain" +value_mode = "filled" + +[templates.values.data] +values = [1, 2] diff --git a/examples/06_dimension_sources/out/data-template.nml b/examples/06_dimension_sources/out/data-template.nml new file mode 100644 index 0000000..d8a4b51 --- /dev/null +++ b/examples/06_dimension_sources/out/data-template.nml @@ -0,0 +1,3 @@ +&data + values(:) = 1, 2 +/ diff --git a/examples/06_dimension_sources/schemas/data.yml b/examples/06_dimension_sources/schemas/data.yml new file mode 100644 index 0000000..40823e3 --- /dev/null +++ b/examples/06_dimension_sources/schemas/data.yml @@ -0,0 +1,10 @@ +title: Data values +x-fortran-namelist: data +type: object +properties: + values: + type: array + items: + type: integer + x-fortran-shape: [n_values] +required: [values] diff --git a/examples/06_dimension_sources/schemas/settings.yml b/examples/06_dimension_sources/schemas/settings.yml new file mode 100644 index 0000000..a670286 --- /dev/null +++ b/examples/06_dimension_sources/schemas/settings.yml @@ -0,0 +1,8 @@ +title: Dimension settings +x-fortran-namelist: settings +type: object +properties: + count: + type: integer + minimum: 1 +required: [count] diff --git a/examples/06_dimension_sources/settings.nml b/examples/06_dimension_sources/settings.nml new file mode 100644 index 0000000..cae2d38 --- /dev/null +++ b/examples/06_dimension_sources/settings.nml @@ -0,0 +1,3 @@ +&settings + count = 3 +/ diff --git a/examples/07_gui/README.md b/examples/07_gui/README.md new file mode 100644 index 0000000..f42c6c6 --- /dev/null +++ b/examples/07_gui/README.md @@ -0,0 +1,109 @@ +# Example 07: GUI project profiles + +This example uses the `nml-tools` GUI to configure a small multi-body gravity +simulation. It demonstrates scalar and array fields for integers, floating-point +numbers, strings, booleans, file paths, and date-times. Four namelist schemas are +split across three file profiles and reused by two project profiles. + +## Requirements and installation + +`nml-tools` requires Python 3.9 or newer. The GUI additionally needs the GUI +extra and one Qt binding. From a source checkout, install it with: + +```bash +python -m pip install -e '.[gui]' PyQt5 +``` + +For an installed release, use: + +```bash +python -m pip install 'nml-tools[gui]' PyQt5 +``` + +`PyQt6`, `PySide2`, or `PySide6` may be used instead of `PyQt5`. Install only +one binding in a clean environment when possible. The GUI extra installs QtPy, +guidata, and NumPy. + +## Example layout + +```text +07_gui/ +├── nml-config.toml # dimensions, schemas, and file profiles +├── schemas/ # four JSON Schema-compatible YAML files +│ ├── run.yml +│ ├── gravity.yml +│ ├── bodies.yml +│ └── outputs.yml +├── input/ # files selectable by file-path fields +│ ├── restart_state.dat +│ ├── body_1.csv +│ ├── body_2.csv +│ └── body_3.csv +└── out/ # generated after saving in the GUI +``` + +The input directory passed to the GUI must contain `nml-config.toml`; schema +paths in that file are relative to the same directory. The output directory must +be writable. It may be empty initially. + +The file profiles are: + +- `setup`: `run` and `gravity` → `setup.nml` +- `bodies`: `bodies` → `bodies.nml` +- `reporting`: `outputs` → `reporting.nml` + +The project profiles are: + +- `simulation`: `setup` and `bodies` +- `report_preview`: `setup` and `reporting` + +This demonstrates how projects can reuse the same `setup` file profile while +presenting only the files relevant to each workflow. + +## Run the example + +From the repository root: + +```bash +mkdir -p examples/07_gui/out +nml-tools gui -i examples/07_gui -o examples/07_gui/out +``` + +Or from this directory: + +```bash +nml-tools gui -i . -o out +``` + +Choose a project in the project selector and click **Add**. Its two file profiles +appear in the project tree and as editable tabs. Use **Save** or **Save all** to +write their namelist files. File browser selections are stored relative to +`out`, which is why the supplied defaults use paths such as +`../input/body_1.csv`. + +## Expected output + +After adding and saving both project profiles, `out` contains: + +```text +out/ +├── setup.nml +├── bodies.nml +└── reporting.nml +``` + +Arrays are written with explicit Fortran indices, for example: + +```fortran +&gravity + axis_codes(1) = 1 + axis_codes(2) = 2 + axis_codes(3) = 3 + gravity_vector(1) = 0.0 + gravity_vector(2) = 0.0 + gravity_vector(3) = -9.81 +/ +``` + +Only files belonging to the selected project need to be saved. Running the same +command again reloads existing values from the namelist files that are present. diff --git a/examples/07_gui/input/body_1.csv b/examples/07_gui/input/body_1.csv new file mode 100644 index 0000000..794f997 --- /dev/null +++ b/examples/07_gui/input/body_1.csv @@ -0,0 +1,2 @@ +x,y,z,vx,vy,vz +0.0,0.0,100.0,0.0,0.0,0.0 diff --git a/examples/07_gui/input/body_2.csv b/examples/07_gui/input/body_2.csv new file mode 100644 index 0000000..e8914cc --- /dev/null +++ b/examples/07_gui/input/body_2.csv @@ -0,0 +1,2 @@ +x,y,z,vx,vy,vz +10.0,0.0,150.0,0.0,0.0,0.0 diff --git a/examples/07_gui/input/body_3.csv b/examples/07_gui/input/body_3.csv new file mode 100644 index 0000000..289074f --- /dev/null +++ b/examples/07_gui/input/body_3.csv @@ -0,0 +1,2 @@ +x,y,z,vx,vy,vz +-10.0,0.0,200.0,0.0,0.0,0.0 diff --git a/examples/07_gui/input/restart_state.dat b/examples/07_gui/input/restart_state.dat new file mode 100644 index 0000000..2e34e62 --- /dev/null +++ b/examples/07_gui/input/restart_state.dat @@ -0,0 +1,3 @@ +# Example restart state +elapsed_seconds = 0 +body_count = 3 diff --git a/examples/07_gui/nml-config.toml b/examples/07_gui/nml-config.toml new file mode 100644 index 0000000..d363dbb --- /dev/null +++ b/examples/07_gui/nml-config.toml @@ -0,0 +1,65 @@ +[kinds] +module = "iso_fortran_env" +integer = ["int32"] +real = ["real64"] +map = { i4 = "int32", dp = "real64" } + +[dimensions.n_bodies] +default = 3 +doc = "Number of falling bodies." + +[dimensions.n_snapshots] +default = 2 +doc = "Number of requested output snapshots." + +[[namelists]] +name = "run" +schema = "schemas/run.yml" + +[[namelists]] +name = "gravity" +schema = "schemas/gravity.yml" + +[[namelists]] +name = "bodies" +schema = "schemas/bodies.yml" + +[[namelists]] +name = "outputs" +schema = "schemas/outputs.yml" + +[[file_profiles]] +name = "setup" +title = "Simulation setup" +description = "Runtime and gravity settings shared by both projects." +default_file = "setup.nml" +namelists = ["run", "gravity"] +required = ["run", "gravity"] + +[[file_profiles]] +name = "bodies" +title = "Falling bodies" +description = "Inputs for the bodies in the full simulation." +default_file = "bodies.nml" +namelists = ["bodies"] +required = ["bodies"] + +[[file_profiles]] +name = "reporting" +title = "Reporting" +description = "Output controls for the reporting preview." +default_file = "reporting.nml" +namelists = ["outputs"] +required = ["outputs"] + +[[project_profiles]] +name = "simulation" +title = "Full simulation" +description = "Simulation setup and falling-body inputs." +file_profiles = ["setup", "bodies"] + +[[project_profiles]] +name = "report_preview" +title = "Reporting preview" +description = "Simulation setup and reporting controls." +file_profiles = ["setup", "reporting"] diff --git a/examples/07_gui/out/bodies.nml b/examples/07_gui/out/bodies.nml new file mode 100644 index 0000000..4bbb0bc --- /dev/null +++ b/examples/07_gui/out/bodies.nml @@ -0,0 +1,20 @@ +&bodies + body_ids(1) = 1 + body_ids(2) = 2 + body_ids(3) = 3 + masses(1) = 1.0 + masses(2) = 2.5 + masses(3) = 5.0 + names(1) = "light" + names(2) = "medium" + names(3) = "heavy" + active(1) = .true. + active(2) = .true. + active(3) = .true. + initial_state_files(1) = "../input/body_1.csv" + initial_state_files(2) = "../input/body_2.csv" + initial_state_files(3) = "../input/body_3.csv" + release_times(1) = "2026-01-01 00:00" + release_times(2) = "2026-01-01 00:05" + release_times(3) = "2026-01-01 00:10" +/ diff --git a/examples/07_gui/out/reporting.nml b/examples/07_gui/out/reporting.nml new file mode 100644 index 0000000..d8f3f10 --- /dev/null +++ b/examples/07_gui/out/reporting.nml @@ -0,0 +1,9 @@ +&outputs + trajectory_file = "trajectory.csv" + write_trajectory = .true. + output_interval_steps = 10 + numeric_precision = 0.001 + output_format = "csv" + snapshot_times(1) = "2026-01-01 00:30" + snapshot_times(2) = "2026-01-01 01:00" +/ diff --git a/examples/07_gui/out/setup.nml b/examples/07_gui/out/setup.nml new file mode 100644 index 0000000..a65f4e3 --- /dev/null +++ b/examples/07_gui/out/setup.nml @@ -0,0 +1,18 @@ +&run + run_id = 1 + label = "gravity-demo" + start_time = "2026-01-01 00:00" + time_step_seconds = 0.1 + enabled = .true. + restart_file = "../input/restart_state.dat" +/ +&gravity + spatial_dimensions = 3 + gravitational_constant = 6.6743e-11 + axis_codes(1) = 1 + axis_codes(2) = 2 + axis_codes(3) = 3 + gravity_vector(1) = 0.0 + gravity_vector(2) = 0.0 + gravity_vector(3) = -9.81 +/ diff --git a/examples/07_gui/schemas/bodies.yml b/examples/07_gui/schemas/bodies.yml new file mode 100644 index 0000000..554808d --- /dev/null +++ b/examples/07_gui/schemas/bodies.yml @@ -0,0 +1,61 @@ +title: Falling bodies +description: Parallel arrays describing the bodies in the simulation. +x-fortran-namelist: bodies +type: object +required: [body_ids, masses, initial_state_files, release_times] +properties: + body_ids: + title: Body identifiers + type: array + x-fortran-shape: n_bodies + items: + type: integer + x-fortran-kind: i4 + minimum: 1 + default: [1, 2, 3] + x-fortran-default-repeat: true + masses: + title: Masses in kilograms + type: array + x-fortran-shape: n_bodies + items: + type: number + x-fortran-kind: dp + exclusiveMinimum: 0.0 + default: [1.0, 2.5, 5.0] + x-fortran-default-repeat: true + names: + title: Body names + type: array + x-fortran-shape: n_bodies + items: + type: string + x-fortran-len: 32 + examples: [[light, medium, heavy]] + active: + title: Active bodies + type: array + x-fortran-shape: n_bodies + items: + type: boolean + examples: [[true, true, true]] + initial_state_files: + title: Initial-state files + type: array + x-fortran-shape: n_bodies + items: + type: string + format: file-path + x-fortran-len: 256 + default: [../input/body_1.csv, ../input/body_2.csv, ../input/body_3.csv] + x-fortran-default-repeat: true + release_times: + title: Release times + type: array + x-fortran-shape: n_bodies + items: + type: string + format: date-time + x-fortran-len: 32 + default: ["2026-01-01 00:00", "2026-01-01 00:05", "2026-01-01 00:10"] + x-fortran-default-repeat: true diff --git a/examples/07_gui/schemas/gravity.yml b/examples/07_gui/schemas/gravity.yml new file mode 100644 index 0000000..1613daa --- /dev/null +++ b/examples/07_gui/schemas/gravity.yml @@ -0,0 +1,43 @@ +title: Gravity configuration +description: Spatial axes and gravitational acceleration. +x-fortran-namelist: gravity +type: object +required: [gravity_vector] +properties: + spatial_dimensions: + title: Number of spatial dimensions + type: integer + x-fortran-kind: i4 + minimum: 1 + maximum: 3 + default: 3 + gravitational_constant: + title: Gravitational constant + description: Gravitational constant in m3 kg-1 s-2. + type: number + x-fortran-kind: dp + exclusiveMinimum: 0.0 + default: 6.6743e-11 + axis_codes: + title: Axis identifiers + type: array + x-fortran-shape: 3 + items: + type: integer + x-fortran-kind: i4 + examples: [[1, 2, 3]] + axes: + "1": + labels: [X, Y, Z] + gravity_vector: + title: Gravity vector + description: Acceleration components in m s-2. + type: array + x-fortran-shape: 3 + items: + type: number + x-fortran-kind: dp + default: [0.0, 0.0, -9.81] + axes: + "1": + labels: [X, Y, Z] diff --git a/examples/07_gui/schemas/outputs.yml b/examples/07_gui/schemas/outputs.yml new file mode 100644 index 0000000..aeabc87 --- /dev/null +++ b/examples/07_gui/schemas/outputs.yml @@ -0,0 +1,44 @@ +title: Output configuration +description: Controls for trajectory and snapshot output. +x-fortran-namelist: outputs +type: object +required: [trajectory_file, output_interval_steps] +properties: + trajectory_file: + title: Trajectory file + type: string + format: file-path + x-fortran-len: 256 + default: trajectory.csv + write_trajectory: + title: Write trajectory + type: boolean + default: true + output_interval_steps: + title: Output interval in steps + type: integer + x-fortran-kind: i4 + minimum: 1 + default: 10 + numeric_precision: + title: Decimal precision + type: number + x-fortran-kind: dp + minimum: 0.0 + default: 0.001 + output_format: + title: Output format + type: string + x-fortran-len: 16 + enum: [csv, netcdf] + default: csv + snapshot_times: + title: Snapshot times + type: array + x-fortran-shape: n_snapshots + items: + type: string + format: date-time + x-fortran-len: 32 + examples: + - ["2026-01-01 00:30", "2026-01-01 01:00"] diff --git a/examples/07_gui/schemas/run.yml b/examples/07_gui/schemas/run.yml new file mode 100644 index 0000000..17ab821 --- /dev/null +++ b/examples/07_gui/schemas/run.yml @@ -0,0 +1,39 @@ +title: Run configuration +description: General controls for the gravity simulation. +x-fortran-namelist: run +type: object +required: [run_id, start_time, time_step_seconds] +properties: + run_id: + title: Run identifier + type: integer + x-fortran-kind: i4 + minimum: 1 + default: 1 + label: + title: Run label + type: string + x-fortran-len: 64 + default: gravity-demo + start_time: + title: Start time + type: string + format: date-time + x-fortran-len: 32 + default: "2026-01-01 00:00" + time_step_seconds: + title: Time step in seconds + type: number + x-fortran-kind: dp + exclusiveMinimum: 0.0 + default: 0.1 + enabled: + title: Run simulation + type: boolean + default: true + restart_file: + title: Optional restart file + type: string + format: file-path + x-fortran-len: 256 + default: ../input/restart_state.dat diff --git a/examples/07_gui/test.toml b/examples/07_gui/test.toml new file mode 100644 index 0000000..4a17e60 --- /dev/null +++ b/examples/07_gui/test.toml @@ -0,0 +1,8 @@ +[[file_profiles]] +name = "ts" +default_file = "ts1.nml" +namelists = ["bodies", "run"] + +[[project_profiles]] +name = "Test" +file_profiles = ["ts"] diff --git a/pyproject.toml b/pyproject.toml index b20345f..c73c344 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,11 @@ dependencies = [ ] [project.optional-dependencies] +gui = [ + "guidata; python_version >= '3.9'", + "numpy; python_version >= '3.9'", + "qtpy; python_version >= '3.9'", +] dev = [ "numpy>=1.24,<3", "pytest-cov", diff --git a/src/nml_tools/_dimensions.py b/src/nml_tools/_dimensions.py new file mode 100644 index 0000000..e2f8ed2 --- /dev/null +++ b/src/nml_tools/_dimensions.py @@ -0,0 +1,145 @@ +"""Private, UI-independent runtime dimension source resolution and inference.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable, Mapping + +from ._namelist_eval import NamelistConstraintError, NamelistEvaluationError, evaluate_group +from ._namelist_parser import DecimalMode, ParsedFile, ParsedGroup +from ._utils import ( + normalize_constant_values, + normalize_runtime_dimensions, + reject_constant_dimension_overlap, + validate_user_fortran_identifier, +) +from .validate import _has_reachable_reference, _normalize_properties + + +@dataclass(frozen=True) +class DimensionSource: + """One top-level integer property supplying a runtime dimension.""" + + namelist: str + property: str + + +def resolve_dimension_source( + source: DimensionSource, + schemas: Mapping[str, Mapping[str, Any]], +) -> DimensionSource: + """Check a source against resolved schemas without evaluating array shapes.""" + validate_user_fortran_identifier(source.namelist, label="dimension source namelist") + validate_user_fortran_identifier(source.property, label="dimension source property") + schema = schemas.get(source.namelist.lower()) + if schema is None: + raise ValueError(f"dimension source references unknown namelist '{source.namelist}'") + if _has_reachable_reference(schema, position="root"): + raise ValueError( + "schema contains unresolved '$ref'; use load_schema() or resolve_schema() " + "before dimension source resolution" + ) + raw_properties = schema.get("properties") + if not isinstance(raw_properties, Mapping): + raise ValueError(f"dimension source namelist '{source.namelist}' must define properties") + properties = _normalize_properties(raw_properties, source.namelist) + matched = properties.get(source.property.lower()) + if matched is None: + raise ValueError( + f"dimension source namelist '{source.namelist}' references " + f"unknown property '{source.property}'" + ) + name, prop = matched + if prop.get("type") != "integer": + raise ValueError( + f"dimension source '{source.namelist}%{name}' must be an intrinsic integer scalar" + ) + return DimensionSource(source.namelist.lower(), name) + + +def infer_runtime_dimensions( + files: Iterable[ParsedFile], + *, + sources: Mapping[str, DimensionSource], + schemas: Mapping[str, Mapping[str, Any]], + defaults: Mapping[str, object], + overrides: Mapping[str, object] | None = None, + constants: Mapping[str, object] | None = None, + decimal_mode: DecimalMode = DecimalMode.POINT, +) -> dict[str, int]: + """Infer explicit source values before callers evaluate complete schemas. + + Explicit overrides bypass source extraction. Null input and schema defaults + do not supply dimensions. Source namelists must be unique across the parsed + inputs for each dimension being inferred. + """ + dimensions = normalize_runtime_dimensions(defaults) + normalized_overrides = normalize_runtime_dimensions(overrides) + dimensions.update(normalized_overrides) + normalized_constants = normalize_constant_values(constants) + reject_constant_dimension_overlap(normalized_constants, dimensions) + groups: dict[str, list[tuple[ParsedFile, ParsedGroup]]] = {} + for parsed in files: + for group in parsed.groups: + groups.setdefault(group.name.lower(), []).append((parsed, group)) + + seen: set[str] = set() + for raw_name, source in sources.items(): + validate_user_fortran_identifier(raw_name, label="runtime dimension") + name = raw_name.lower() + if name in seen: + raise ValueError(f"runtime dimension '{raw_name}' duplicates another source name") + seen.add(name) + if name not in dimensions: + raise ValueError(f"dimension source '{raw_name}' has no configured default") + resolved = resolve_dimension_source(source, schemas) + if name in normalized_overrides: + continue + matches = groups.get(resolved.namelist, []) + if len(matches) > 1: + parsed, group = matches[1] + first_file, first_group = matches[0] + raise NamelistEvaluationError( + f"dimension '{name}' has ambiguous source namelist '{resolved.namelist}'; " + f"also present in {first_file.source}:{first_group.span.start.line}", + source=parsed.source, + span=group.span, + ) + if not matches: + continue + parsed, group = matches[0] + prop = schemas[resolved.namelist]["properties"][resolved.property] + scalar_schema = { + "type": "object", + "x-fortran-namelist": resolved.namelist, + "properties": {resolved.property: prop}, + } + scalar_group = ParsedGroup( + group.name, + tuple( + assignment + for assignment in group.assignments + if assignment.designator.parts[0].name.lower() == resolved.property.lower() + ), + group.span, + ) + evaluated = evaluate_group( + scalar_group, + scalar_schema, + source=parsed.source, + constants=normalized_constants, + dimensions=dimensions, + decimal_mode=decimal_mode, + ) + state = evaluated.states.get((resolved.property.lower(), (), None)) + if state is None or not state.explicitly_assigned: + continue + if state.value <= 0: + raise NamelistConstraintError( + f"dimension '{name}' source '{resolved.namelist}%{resolved.property}' " + "must be positive", + source=parsed.source, + span=state.value_source_span or group.span, + ) + dimensions[name] = state.value + return dimensions diff --git a/src/nml_tools/_namelist_eval.py b/src/nml_tools/_namelist_eval.py index d689dd9..452a43b 100644 --- a/src/nml_tools/_namelist_eval.py +++ b/src/nml_tools/_namelist_eval.py @@ -74,6 +74,8 @@ class LeafState: explicitly_assigned: bool null_consumed: bool source_span: SourceSpan | None + # Last explicit value; trailing null input retains this location. + value_source_span: SourceSpan | None = None @dataclass(frozen=True) @@ -128,6 +130,7 @@ class _MutableState: explicitly_assigned: bool = False null_consumed: bool = False source_span: SourceSpan | None = None + value_source_span: SourceSpan | None = None @dataclass(frozen=True) @@ -364,6 +367,7 @@ def _evaluate_group_model( state.has_value = True state.explicitly_assigned = True state.source_span = parsed_value.span + state.value_source_span = parsed_value.span explicit_roots.add(target.root.key) if target.root.derived and target.component_key is not None: instance = (target.root.key, target.coordinates) @@ -409,6 +413,7 @@ def _evaluate_group_model( state.explicitly_assigned, state.null_consumed, state.source_span, + state.value_source_span, ) for key, state in states.items() } diff --git a/src/nml_tools/cli.py b/src/nml_tools/cli.py index 97861e6..f968002 100644 --- a/src/nml_tools/cli.py +++ b/src/nml_tools/cli.py @@ -14,8 +14,9 @@ from packaging.specifiers import InvalidSpecifier, SpecifierSet from packaging.version import InvalidVersion, Version +from ._dimensions import DimensionSource, infer_runtime_dimensions, resolve_dimension_source from ._namelist_eval import evaluate_group -from ._namelist_parser import NamelistSyntaxError, ParsedGroup, parse_namelist +from ._namelist_parser import NamelistSyntaxError, ParsedFile, ParsedGroup, parse_namelist from ._utils import constant_dimension_overlap, validate_user_fortran_identifier from ._version import __version__ from .codegen_f2py import ( @@ -129,6 +130,24 @@ class FileProfile: description: str | None = None +@dataclass(frozen=True) +class ProjectProfile: + """Named set of file profiles required for a project setup.""" + + name: str + key: str + file_profiles: list[str] + title: str | None = None + description: str | None = None + + +@dataclass(frozen=True) +class _ConfigMetadata: + dimension_sources: dict[str, DimensionSource] + file_profiles: dict[str, FileProfile] + project_profiles: dict[str, ProjectProfile] + + def _configure_logging(verbose: int, quiet: int) -> None: base_level = logging.INFO level = base_level - (10 * verbose) + (10 * quiet) @@ -852,6 +871,93 @@ def _iter_file_profiles( return profiles +def _iter_project_profiles( + config: dict[str, Any], + file_profiles: dict[str, FileProfile], +) -> dict[str, ProjectProfile]: + raw_entries = config.get("project_profiles", []) + if not isinstance(raw_entries, list): + raise click.ClickException("config 'project_profiles' must be a list") + profiles: dict[str, ProjectProfile] = {} + for entry in raw_entries: + if not isinstance(entry, dict): + raise click.ClickException("each project_profiles entry must be a table") + name = entry.get("name") + if not isinstance(name, str) or not name.strip(): + raise click.ClickException("project_profiles entry must define non-empty string 'name'") + key = name.lower() + label = f"project profile '{name}'" + if key in profiles: + raise click.ClickException(f"{label} duplicates another profile") + raw_members = entry.get("file_profiles") + if not isinstance(raw_members, list) or not raw_members: + raise click.ClickException(f"{label} must define non-empty 'file_profiles'") + members: list[str] = [] + seen: set[str] = set() + for member in raw_members: + if not isinstance(member, str) or not member.strip(): + raise click.ClickException(f"{label} 'file_profiles' entries must be strings") + member_key = member.lower() + if member_key in seen: + raise click.ClickException( + f"{label} file profile '{member}' duplicates another name" + ) + if member_key not in file_profiles: + raise click.ClickException(f"{label} references unknown file profile '{member}'") + seen.add(member_key) + members.append(member_key) + profiles[key] = ProjectProfile( + name=name, + key=key, + file_profiles=members, + title=_optional_string(entry, "title", label=label), + description=_optional_string(entry, "description", label=label), + ) + return profiles + + +def _load_dimension_sources( + config: dict[str, Any], + registry: dict[str, LoadedNamelist], +) -> dict[str, DimensionSource]: + """Retain source metadata separately from generated dimension defaults.""" + sources: dict[str, DimensionSource] = {} + schemas = {key: loaded.schema for key, loaded in registry.items()} + for name, entry in (config.get("dimensions") or {}).items(): + if "source" not in entry: + continue + raw_source = entry["source"] + label = f"config dimension '{name}' source" + if not isinstance(raw_source, dict): + raise click.ClickException(f"{label} must be a table") + namelist = raw_source.get("namelist") + prop = raw_source.get("property", name.strip()) + if not isinstance(namelist, str) or not namelist.strip(): + raise click.ClickException(f"{label} must define non-empty string 'namelist'") + if not isinstance(prop, str) or not prop.strip(): + raise click.ClickException(f"{label} 'property' must be a non-empty string") + try: + sources[name.strip().lower()] = resolve_dimension_source( + DimensionSource(namelist, prop), schemas + ) + except ValueError as exc: + raise click.ClickException(f"{label}: {exc}") from exc + return sources + + +def _load_config_metadata( + config: dict[str, Any], + registry: dict[str, LoadedNamelist], +) -> _ConfigMetadata: + """Validate independent metadata sections after defaults and schemas load.""" + file_profiles = _iter_file_profiles(config, registry) + return _ConfigMetadata( + dimension_sources=_load_dimension_sources(config, registry), + file_profiles=file_profiles, + project_profiles=_iter_project_profiles(config, file_profiles), + ) + + def _iter_templates( config: dict[str, Any], base_dir: Path, @@ -975,7 +1081,7 @@ def _collect_generated_outputs( loaded_namelists = _load_namelist_registry(config, base_dir, resolver) loaded_by_key = _namelist_registry_by_key(loaded_namelists) - profiles = _iter_file_profiles(config, loaded_by_key) + profiles = _load_config_metadata(config, loaded_by_key).file_profiles logger.debug("Found %d schema entries", len(loaded_namelists)) loaded_entries: list[dict[str, Any]] = [ {"entry": loaded.entry, "schema": loaded.schema} for loaded in loaded_namelists @@ -1279,6 +1385,34 @@ def cli(verbose: int, quiet: int) -> None: _configure_logging(verbose, quiet) +@cli.command("gui", context_settings=_CONTEXT_SETTINGS) +@click.option( + "--input-path", "-i", + type=click.Path(exists=True, file_okay=False, path_type=Path), + help="Directory containing nml-config.toml and schemas (default: current directory).", +) +@click.option( + "--output-path", "-o", + type=click.Path(file_okay=False, path_type=Path), + help="Directory for namelist files (default: input path).", +) +def gui(input_path: Path | None, output_path: Path | None) -> None: + """Edit and save namelist files using schema-driven Qt forms.""" + try: + from .gui import launch_gui + + exit_code = launch_gui(input_path, output_path) + except ImportError as exc: + raise click.ClickException( + "GUI dependencies are unavailable; install 'nml-tools[gui]' " + f"and a Qt binding: {exc}" + ) from exc + except (OSError, RuntimeError, ValueError) as exc: + raise click.ClickException(f"failed to start GUI: {exc}") from exc + if exit_code: + raise Exit(exit_code) + + @cli.command("generate", context_settings=_CONTEXT_SETTINGS) @click.option( "--config", @@ -1346,18 +1480,12 @@ def gen_fortran(config_path: Path | None) -> None: kind_module, kind_map, kind_allowlist = _load_kind_settings(config) f2cmap_path, f2py_c_types = _load_f2py_settings(config, base_dir) resolver = SchemaResolver() - entries = _iter_namelists(config, base_dir) - logger.info("Found %d schema entries", len(entries)) + loaded_namelists = _load_namelist_registry(config, base_dir, resolver) + _load_config_metadata(config, _namelist_registry_by_key(loaded_namelists)) + logger.info("Found %d schema entries", len(loaded_namelists)) loaded_entries: list[dict[str, Any]] = [] - for entry in entries: - schema_path = entry["schema"] - if schema_path is None: - raise click.ClickException("namelists entry missing schema path") - try: - logger.info("Loading schema %s", schema_path) - schema = load_schema(schema_path, resolver=resolver) - except (FileNotFoundError, ValueError) as exc: - raise click.ClickException(str(exc)) from exc + for loaded in loaded_namelists: + entry, schema = loaded.entry, loaded.schema loaded_entries.append({"entry": entry, "schema": schema}) mod_path = entry["mod_path"] if mod_path is None: @@ -1419,6 +1547,18 @@ def gen_fortran(config_path: Path | None) -> None: ) +def _read_namelist(path: Path) -> ParsedFile: + try: + logger.info("Reading namelist %s", path) + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + raise click.ClickException(f"failed to read namelist: {exc}") from exc + try: + return parse_namelist(text, source=str(path)) + except NamelistSyntaxError as exc: + raise click.ClickException(f"failed to parse namelist: {exc}") from exc + + @cli.command("validate", context_settings=_CONTEXT_SETTINGS) @click.option( "--config", @@ -1437,6 +1577,11 @@ def gen_fortran(config_path: Path | None) -> None: "input_option", type=click.Path(exists=True, dir_okay=False, path_type=Path), ) +@click.option( + "--dim-file", + type=click.Path(exists=True, dir_okay=False, path_type=Path), + help="Additional namelist file supplying configured dimension sources.", +) @click.option( "--profile", "profile_name", @@ -1471,6 +1616,7 @@ def validate( constant_args: tuple[tuple[str, int], ...], dimension_args: tuple[tuple[str, int], ...], input_path: Path | None, + dim_file: Path | None = None, ) -> None: """Validate a namelist file against schema definitions.""" if input_option is not None and input_path is not None: @@ -1478,10 +1624,14 @@ def validate( input_path = input_option or input_path if input_path is None: raise click.ClickException("input path is required") + if dim_file is not None and schema_paths and config_path is None: + raise click.ClickException("--dim-file requires config with dimension source metadata") constants = _parse_cli_constants(constant_args) dimension_overrides = _parse_cli_dimensions(dimension_args) dimensions: dict[str, int] = {} schemas: list[dict[str, Any]] = [] + source_schemas: dict[str, dict[str, Any]] = {} + metadata = _ConfigMetadata({}, {}, {}) required_namelist_keys: set[str] | None resolver = SchemaResolver() @@ -1494,9 +1644,13 @@ def validate( cfg_constants, _ = _load_constants(config) dimensions, _ = _load_dimensions(config, cfg_constants) constants = {**cfg_constants, **constants} - dimensions = {**dimensions, **dimension_overrides} - else: - dimensions = dimension_overrides + loaded_by_key = ( + _namelist_registry_by_key( + _load_namelist_registry(config, config_path.parent, resolver) + ) if "namelists" in config else {} + ) + metadata = _load_config_metadata(config, loaded_by_key) + source_schemas = {key: loaded.schema for key, loaded in loaded_by_key.items()} for schema_file in schema_paths: try: logger.info("Loading schema %s", schema_file) @@ -1507,16 +1661,15 @@ def validate( else: config, config_path = _load_config_checked(config_path) logger.info("Loading config from %s", config_path) - base_dir = config_path.parent cfg_constants, _ = _load_constants(config) dimensions, _ = _load_dimensions(config, cfg_constants) constants = {**cfg_constants, **constants} - dimensions = {**dimensions, **dimension_overrides} - loaded_namelists = _load_namelist_registry(config, base_dir, resolver) + loaded_namelists = _load_namelist_registry(config, config_path.parent, resolver) loaded_by_key = _namelist_registry_by_key(loaded_namelists) + metadata = _load_config_metadata(config, loaded_by_key) + source_schemas = {key: loaded.schema for key, loaded in loaded_by_key.items()} if profile_name is not None: - profiles = _iter_file_profiles(config, loaded_by_key) - profile = profiles.get(profile_name.lower()) + profile = metadata.file_profiles.get(profile_name.lower()) if profile is None: raise click.ClickException(f"unknown file profile '{profile_name}'") loaded_namelists = [loaded_by_key[key] for key in profile.namelists] @@ -1528,17 +1681,23 @@ def validate( if not schemas: raise click.ClickException("no schemas provided for validation") - _reject_constant_dimension_overlap(constants, dimensions) + _reject_constant_dimension_overlap(constants, {**dimensions, **dimension_overrides}) + parsed_file = _read_namelist(input_path) + parsed_inputs = [parsed_file] + if dim_file is not None and dim_file.resolve() != input_path.resolve(): + parsed_inputs.append(_read_namelist(dim_file)) try: - logger.info("Reading namelist %s", input_path) - namelist_text = input_path.read_text(encoding="utf-8") - except (OSError, UnicodeError) as exc: - raise click.ClickException(f"failed to read namelist: {exc}") from exc - try: - parsed_file = parse_namelist(namelist_text, source=str(input_path)) - except NamelistSyntaxError as exc: - raise click.ClickException(f"failed to parse namelist: {exc}") from exc + dimensions = infer_runtime_dimensions( + parsed_inputs, + sources=metadata.dimension_sources, + schemas=source_schemas, + defaults=dimensions, + overrides=dimension_overrides, + constants=constants, + ) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc file_entries: dict[str, tuple[str, ParsedGroup]] = {} for group in parsed_file.groups: @@ -1601,18 +1760,12 @@ def gen_markdown(config_path: Path | None) -> None: _, md_doxygen_id_from_name, md_add_toc_statement, _ = _load_documentation_settings( config ) - entries = _iter_namelists(config, base_dir) resolver = SchemaResolver() - logger.info("Found %d schema entries", len(entries)) - for entry in entries: - schema_path = entry["schema"] - if schema_path is None: - raise click.ClickException("namelists entry missing schema path") - try: - logger.info("Loading schema %s", schema_path) - schema = load_schema(schema_path, resolver=resolver) - except (FileNotFoundError, ValueError) as exc: - raise click.ClickException(str(exc)) from exc + loaded_namelists = _load_namelist_registry(config, base_dir, resolver) + _load_config_metadata(config, _namelist_registry_by_key(loaded_namelists)) + logger.info("Found %d schema entries", len(loaded_namelists)) + for loaded in loaded_namelists: + entry, schema = loaded.entry, loaded.schema doc_path = entry["doc_path"] if doc_path is None: continue @@ -1649,7 +1802,7 @@ def gen_template(config_path: Path | None) -> None: resolver = SchemaResolver() loaded_namelists = _load_namelist_registry(config, base_dir, resolver) loaded_by_key = _namelist_registry_by_key(loaded_namelists) - profiles = _iter_file_profiles(config, loaded_by_key) + profiles = _load_config_metadata(config, loaded_by_key).file_profiles templates = _iter_templates(config, base_dir, loaded_by_key, profiles) if not templates: raise click.ClickException("config must define non-empty 'templates'") diff --git a/src/nml_tools/gui/__init__.py b/src/nml_tools/gui/__init__.py new file mode 100644 index 0000000..fa2881d --- /dev/null +++ b/src/nml_tools/gui/__init__.py @@ -0,0 +1,33 @@ +"""Optional Qt namelist editor; Qt is imported only when launching the GUI.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +__all__ = ["launch_gui"] + + +def launch_gui( + schemas_dir: Path | str | None = None, + output_dir: Path | str | None = None, + project_profiles: str + | Mapping[str, Mapping[str, list[str]]] + | Mapping[str, list[str]] + | None = None, + initial_values: Mapping[str, Any] | None = None, + initial_dimensions: Mapping[str, int] | None = None, +) -> int: + """Edit selected projects; nested mappings select their files/namelists. + + Explicit project-profile selections are loaded into the tree. None exposes + every configured project in the dropdown without loading it. The legacy + flat file-profile mapping is still accepted. Values remain keyed by file + profile and override existing namelist input. Output defaults to schemas_dir. + """ + from .app import launch_gui as _launch_gui + + return _launch_gui( + schemas_dir, output_dir, project_profiles, initial_values, initial_dimensions + ) diff --git a/src/nml_tools/gui/app.py b/src/nml_tools/gui/app.py new file mode 100644 index 0000000..f033d78 --- /dev/null +++ b/src/nml_tools/gui/app.py @@ -0,0 +1,1059 @@ +# mypy: disable-error-code="attr-defined, import-not-found, misc" +"""Project-oriented Qt editor for direct namelist input and output.""" + +from __future__ import annotations + +import copy +import sys +from collections.abc import Mapping +from dataclasses import replace +from pathlib import Path +from typing import Any + +from qtpy.QtCore import QSignalBlocker, Qt +from qtpy.QtWidgets import ( + QAbstractItemView, + QApplication, + QComboBox, + QDialog, + QFileDialog, + QHeaderView, + QInputDialog, + QListWidget, + QListWidgetItem, + QMessageBox, + QScrollArea, + QSpinBox, + QTabBar, + QToolButton, + QTreeWidgetItem, + QWidget, +) + +from .fields import NamelistForm, _exec +from .model import ( + GuiProfile, + GuiProject, + GuiProjectProfile, + _normalize_dimensions, + _normalize_profile_values, + _profile_path, + create_virtual_project, + dimension_source_values, + discover_project_files, + ensure_dimension_sources, + load_profile, + load_project, + load_project_profile_file, + overlay_values, + recover_dimensions, + save_profiles, + save_project_profile, + suggestion, +) +from .ui import load_ui + +_UNSET = object() + + +def _untouched_page_values( + schema: Mapping[str, Any], source: Mapping[str, Any] | None, sizes: Mapping[str, int] +) -> dict[str, Any]: + """Collect loaded values and defaults without constructing an unseen page.""" + properties = schema.get("properties", {}) + required = {str(name).lower() for name in schema.get("required", [])} + raw = source or {} + result: dict[str, Any] = {} + for name, child in properties.items(): + if not isinstance(name, str) or not isinstance(child, Mapping): + continue + value = _untouched_value( + child, + raw.get(name, _UNSET), + name.lower() in required or "default" in child, + name, + sizes, + ) + if value is not _UNSET: + result[name] = value + return result + + +def _untouched_value( + schema: Mapping[str, Any], + value: Any, + required: bool, + name: str, + sizes: Mapping[str, int], +) -> Any: + """Apply GUI default/required rules to an unvisited field.""" + kind = schema.get("type") + if value is not _UNSET: + if kind == "string" and value == "": + if "default" in schema: + return copy.deepcopy(schema["default"]) + if required: + raise ValueError(f"required field '{name}' has no value") + return _UNSET + if kind != "object" or not isinstance(value, Mapping): + return copy.deepcopy(value) + base = schema.get("default", {}) + source = overlay_values(base, value) if isinstance(base, Mapping) else dict(value) + elif "default" in schema: + return suggestion(schema, sizes) if kind == "array" else copy.deepcopy(schema["default"]) + elif kind != "object": + if required: + raise ValueError(f"required field '{name}' has no value") + return _UNSET + else: + source = {} + if not required: + return _UNSET + properties = schema.get("properties", {}) + child_required = {str(item).lower() for item in schema.get("required", [])} + result: dict[str, Any] = {} + for child_name, child in properties.items(): + if not isinstance(child_name, str) or not isinstance(child, Mapping): + continue + child_value = _untouched_value( + child, + source.get(child_name, _UNSET), + child_name.lower() in child_required or "default" in child, + f"{name}%{child_name}", + sizes, + ) + if child_value is not _UNSET: + result[child_name] = child_value + if required and not result: + raise ValueError(f"required field '{name}' has no value") + return result + + +class ProfileTab(QWidget): + """Ordered namelist pages for one file profile.""" + + def __init__( + self, + project: GuiProject, + profile: GuiProfile, + values: Mapping[str, Any], + dimensions: Mapping[str, int], + parent: QWidget | None = None, + *, + fit_arrays: bool = False, + ) -> None: + super().__init__(parent) + load_ui("profile_tab.ui", self) + self.project = project + self.profile = profile + self.dimensions = dict(dimensions) + self.saved_dimensions = dict(dimensions) + self._values = copy.deepcopy(dict(values)) + self._sizes = {**project.constants, **dimensions} + self._fit_arrays = fit_arrays + self.forms: dict[str, NamelistForm] = {} + self.form_saved: dict[str, dict[str, Any]] = {} + self.selector: QComboBox = self.namelistComboBox + self.stack = self.namelistStack + self.back = self.backButton + self.next = self.nextButton + self.restore = self.restoreButton + self.cancel = self.cancelButton + self.save = self.saveButton + self.descriptionLabel.setText(profile.description or "") + self.descriptionLabel.setVisible(bool(profile.description)) + for page in profile.pages: + self.selector.addItem(page.name, page.key) + self.stack.addWidget(QWidget(self.stack)) + self.selector.currentIndexChanged.connect(self._page_changed) + self.back.clicked.connect( + lambda: self.selector.setCurrentIndex(self.selector.currentIndex() - 1) + ) + self.next.clicked.connect( + lambda: self.selector.setCurrentIndex(self.selector.currentIndex() + 1) + ) + self.restore.clicked.connect(self.restore_page) + self.selector.setCurrentIndex(-1) + self.selector.setCurrentIndex(0 if profile.pages else -1) + self.saved_values = copy.deepcopy(dict(values)) + + def values(self) -> dict[str, Any]: + """Return values from every namelist page.""" + result = {} + for index, page in enumerate(self.profile.pages): + form = self.forms.get(page.name) + result[page.name] = ( + (form or self._ensure_form(index)).values() + if form is not None or self._fit_arrays + else _untouched_page_values(page.schema, self._values.get(page.name), self._sizes) + ) + return result + + def is_dirty(self) -> bool: + """Check only pages already visited by the user.""" + if self.dimensions != self.saved_dimensions: + return True + try: + return any(form.values() != self.form_saved[name] for name, form in self.forms.items()) + except ValueError: + return True + + def mark_saved(self, values: Mapping[str, Any]) -> None: + """Record a successful save without rebuilding any unvisited page.""" + self.saved_values = copy.deepcopy(dict(values)) + self.saved_dimensions = dict(self.dimensions) + for name, form in self.forms.items(): + self.form_saved[name] = copy.deepcopy(form.values()) + + def restore_page(self) -> None: + """Restore the active namelist page.""" + index = self.selector.currentIndex() + if index >= 0: + self._ensure_form(index).reset() + + def restore_all(self) -> None: + """Restore every page in this file profile.""" + for index in range(len(self.profile.pages)): + self._ensure_form(index).reset() + + def select_page(self, key: str) -> None: + """Select a namelist page by normalized name.""" + index = self.selector.findData(key) + if index >= 0: + self.selector.setCurrentIndex(index) + + def _page_changed(self, index: int) -> None: + """Build the selected page on first use and display it.""" + if index >= 0: + self._ensure_form(index) + self.stack.setCurrentIndex(index) + self._update_navigation(index) + + def _ensure_form(self, index: int) -> NamelistForm: + """Construct one schema page only when it becomes visible or saved.""" + page = self.profile.pages[index] + existing = self.forms.get(page.name) + if existing is not None: + return existing + read_only = { + source.property.lower() + for source in self.project.dimension_sources.values() + if source.namelist == page.key + } + form = NamelistForm( + page.schema, + self._values.get(page.name), + self._sizes, + fit_arrays=self._fit_arrays, + output_root=self.project.output_root, + read_only=read_only, + ) + scroll = QScrollArea(self) + scroll.setWidgetResizable(True) + scroll.setWidget(form) + old = self.stack.widget(index) + self.stack.removeWidget(old) + old.deleteLater() + self.stack.insertWidget(index, scroll) + self.forms[page.name] = form + try: + self.form_saved[page.name] = copy.deepcopy(form.values()) + except ValueError: + self.form_saved[page.name] = copy.deepcopy(dict(self._values.get(page.name, {}))) + return form + + def _update_navigation(self, index: int) -> None: + """Update Back/Next availability for the active page.""" + self.back.setEnabled(index > 0) + self.next.setEnabled(0 <= index < self.selector.count() - 1) + self.restore.setEnabled(index >= 0) + + +class ProfileConfigTab(QWidget): + """Create, add, or import one file profile within a project.""" + + def __init__( + self, + project: GuiProject, + dimensions: Mapping[str, int], + parent: QWidget, + ) -> None: + super().__init__(parent) + load_ui("profile_config.ui", self) + self.project = project + self.source_profile: GuiProfile | None = None + self.source_profiles: dict[str, GuiProfile] = {} + self.source_combo = self.sourceComboBox_loadProfile + self.browse = self.browseButton_loadProfile + self.run = self.runButton + self.available_schemas: QListWidget = self.availableSchemasList + self.selected_schemas: QListWidget = self.selectedSchemasList + self.profile_name = self.profileNameEdit + self.default_filename = self.defaultFileEdit + self.dimension_boxes: dict[str, QSpinBox] = {} + mode = QAbstractItemView.ExtendedSelection + self.available_schemas.setSelectionMode(mode) + self.selected_schemas.setSelectionMode(mode) + for name, default in project.default_dimensions.items(): + box = QSpinBox(self.dimensionsGroup) + box.setRange(1, 2_147_483_647) + box.setValue(dimensions.get(name, default)) + self.dimensionsLayout.addRow(name, box) + self.dimension_boxes[name] = box + self.dimensionsGroup.setVisible(bool(self.dimension_boxes)) + for page in project.namelists: + self.available_schemas.addItem(ConfigurationDialog._schema_item(page.name, page.key)) + for button, source, target, all_items in ( + (self.addSchemaButton, self.available_schemas, self.selected_schemas, False), + (self.addAllSchemasButton, self.available_schemas, self.selected_schemas, True), + (self.removeSchemaButton, self.selected_schemas, self.available_schemas, False), + (self.removeAllSchemasButton, self.selected_schemas, self.available_schemas, True), + ): + callback = ( + ConfigurationDialog._move_all if all_items else ConfigurationDialog._move_selected + ) + button.clicked.connect(lambda _checked=False, s=source, t=target, fn=callback: fn(s, t)) + + def dimensions(self) -> dict[str, int]: + """Return runtime dimensions entered on this tab.""" + return {name: box.value() for name, box in self.dimension_boxes.items()} + + def schema_keys(self) -> list[str]: + """Return selected namelist keys in visible order.""" + return [ + str(self.selected_schemas.item(index).data(Qt.ItemDataRole.UserRole)) + for index in range(self.selected_schemas.count()) + ] + + def reset_builder(self, profiles: tuple[GuiProfile, ...]) -> None: + """Reset the file-profile builder and refresh reusable definitions.""" + self.source_combo.clear() + self.source_combo.addItem("", ("new", "")) + self.source_profiles = {profile.key: profile for profile in self.project.profiles} + for path in discover_project_files(self.project): + try: + discovered = load_project_profile_file(self.project, path) + except (OSError, ValueError): + continue + for profile in discovered.profiles: + self.source_profiles.setdefault(profile.key, profile) + self.source_profiles.update((profile.key, profile) for profile in profiles) + for profile in self.source_profiles.values(): + self.source_combo.addItem(profile.title, ("profile", profile.key)) + self.clear_builder() + + def clear_builder(self) -> None: + """Clear the editable definition for a new file profile.""" + self.source_profile = None + self.profile_name.clear() + self.default_filename.clear() + self.available_schemas.clear() + self.selected_schemas.clear() + for page in self.project.namelists: + self.available_schemas.addItem(ConfigurationDialog._schema_item(page.name, page.key)) + + +class ConfigurationDialog(QDialog): + """Edit one or more project profiles and their direct namelist files.""" + + def __init__( + self, + project: GuiProject, + parent: QWidget | None = None, + initial_values: Mapping[str, Any] | None = None, + initial_dimensions: Mapping[str, int] | None = None, + preload_projects: bool = False, + ) -> None: + super().__init__(parent) + load_ui("app.ui", self) + self.project = project + if initial_values is not None and not isinstance(initial_values, Mapping): + raise ValueError("initial_values must map file profiles to namelist values") + self.initial_values: dict[str, Any] = {} + for name, values in (initial_values or {}).items(): + if not isinstance(name, str): + raise ValueError("initial value profile names must be strings") + try: + profile = project.profile(name) + except KeyError as exc: + raise ValueError(f"unknown initial value profile '{name}'") from exc + if profile.key in self.initial_values: + raise ValueError(f"duplicate initial value profile '{name}'") + self.initial_values[profile.key] = values + self.dimension_overrides = initial_dimensions or {} + profiles = project.project_profiles or ( + GuiProjectProfile("default", "default", "Default", None, project.profiles, custom=True), + ) + self.available_projects = {profile.key: profile for profile in profiles} + self.loaded_projects: dict[str, GuiProjectProfile] = {} + self.project_items: dict[str, QTreeWidgetItem] = {} + self.project_editors: dict[str, dict[Path, ProfileTab]] = {} + self.project_configs: dict[str, ProfileConfigTab] = {} + self.project_dimensions: dict[str, dict[str, int]] = {} + self.active_project: str | None = None + self.editors: dict[Path, ProfileTab] = {} + self.config_tab: ProfileConfigTab | None = None + self.plus_tab = QWidget(self.tabs) + self.tabs.setTabsClosable(True) + self.splitter.setSizes([self.width(), self.width() * 3]) + self.treeWidget_projectStructure.setColumnCount(2) + self.treeWidget_projectStructure.setHeaderLabels(["Project structure", ""]) + header = self.treeWidget_projectStructure.header() + header.setStretchLastSection(False) + header.setMinimumSectionSize(28) + header.setSectionResizeMode(0, QHeaderView.Stretch) + header.setSectionResizeMode(1, QHeaderView.Fixed) + self.treeWidget_projectStructure.setColumnWidth(1, 28) + self._populate_project_sources() + self.browseButton_loadProject.clicked.connect(self._browse_project) + self.browseButton_addProject.clicked.connect(self._add_selected_project) + self.treeWidget_projectStructure.currentItemChanged.connect(self._tree_selected) + self.tabs.currentChanged.connect(self._tab_changed) + self.tabs.tabCloseRequested.connect(self._close_tab) + self.restoreAllButton.clicked.connect(self._restore_all) + self.saveAllButton.clicked.connect(self._save_all) + self.closeButton.clicked.connect(self.accept) + if preload_projects: + try: + for project_profile in self.available_projects.values(): + self._register_project(project_profile) + first = next(iter(self.loaded_projects)) + self.treeWidget_projectStructure.setCurrentItem(self.project_items[first]) + except (OSError, ValueError, KeyError) as exc: + QMessageBox.critical(self, "Invalid configuration", str(exc)) + + @staticmethod + def _schema_item(name: str, key: str) -> QListWidgetItem: + """Create a schema-list item carrying its normalized name.""" + item = QListWidgetItem(name) + item.setData(Qt.ItemDataRole.UserRole, key) + return item + + @staticmethod + def _move_selected(source: QListWidget, target: QListWidget) -> None: + """Move selected schemas between profile-builder lists.""" + for item in source.selectedItems(): + target.addItem(source.takeItem(source.row(item))) + + @staticmethod + def _move_all(source: QListWidget, target: QListWidget) -> None: + """Move every schema between profile-builder lists.""" + while source.count(): + target.addItem(source.takeItem(0)) + + def _populate_project_sources(self) -> None: + """List configured and discoverable project-profile sources.""" + combo = self.sourceComboBox_loadProject + combo.addItem("", ("new", "")) + for profile in self.available_projects.values(): + combo.addItem(profile.title, ("configured", profile.key)) + for path in discover_project_files(self.project): + combo.addItem(path.name, ("path", str(path))) + + def _browse_project(self) -> None: + """Add a browsed TOML path to the source chooser without loading it.""" + name, _ = QFileDialog.getOpenFileName( + self, "Load project profile", str(self.project.root), "TOML files (*.toml)" + ) + if name: + data = ("path", str(Path(name).resolve())) + index = next( + ( + index + for index in range(self.sourceComboBox_loadProject.count()) + if self.sourceComboBox_loadProject.itemData(index) == data + ), + -1, + ) + if index < 0: + self.sourceComboBox_loadProject.addItem(Path(name).name, data) + index = self.sourceComboBox_loadProject.count() - 1 + self.sourceComboBox_loadProject.setCurrentIndex(index) + + def _add_selected_project(self) -> None: + """Load the selected configured, external, or new project into the tree.""" + try: + kind, value = self.sourceComboBox_loadProject.currentData() + if kind == "new": + name, accepted = QInputDialog.getText(self, "New project", "Project name") + name = name.strip() + if not accepted: + return + if not name or not name.replace("-", "_").isalnum(): + raise ValueError("project name may contain only letters, numbers, '-' and '_'") + profile = GuiProjectProfile(name, name.lower(), name, None, (), custom=True) + if profile.key in self.loaded_projects: + raise ValueError(f"project '{name}' is already loaded") + elif kind == "configured": + profile = self.available_projects[value] + else: + profile = load_project_profile_file(self.project, Path(value)) + self._register_project(profile) + self.treeWidget_projectStructure.setCurrentItem(self.project_items[profile.key]) + except (OSError, ValueError, KeyError, TypeError) as exc: + QMessageBox.critical(self, "Load project", str(exc)) + + def _register_project(self, profile: GuiProjectProfile) -> None: + """Validate and add one project profile without duplicating it.""" + if profile.key in self.loaded_projects: + return + profiles = ensure_dimension_sources(self.project, profile.profiles) + profile = replace(profile, profiles=profiles) + paths = [_profile_path(self.project, item) for item in profiles] + for loaded in self.loaded_projects.values(): + for left in loaded.profiles: + for right in profiles: + if left.default_file == right.default_file and left != right: + raise ValueError( + f"projects '{loaded.name}' and '{profile.name}' define " + f"'{left.default_file}' differently" + ) + if len(paths) != len(set(paths)): + raise ValueError(f"project '{profile.name}' writes more than one profile to one file") + dimensions = recover_dimensions(self.project, paths, self.dimension_overrides) + source_values = dimension_source_values(self.project, profiles, dimensions) + prepared: dict[Path, ProfileTab] = {} + try: + for item, path in zip(profiles, paths): + values = load_profile(self.project, item, dimensions) + supplied = self.initial_values.get(item.key, {}) + if supplied: + values = overlay_values( + values, + _normalize_profile_values( + supplied, + item, + {**self.project.constants, **dimensions}, + ), + ) + values = overlay_values(values, source_values.get(item.key, {})) + prepared[path] = ProfileTab(self.project, item, values, dimensions, self) + prepared[path].hide() + except Exception: + for editor in prepared.values(): + editor.deleteLater() + raise + self.loaded_projects[profile.key] = profile + self.project_dimensions[profile.key] = dimensions + self.project_editors[profile.key] = prepared + for editor in prepared.values(): + editor.save.clicked.connect(lambda _checked=False, item=editor: self._save([item])) + editor.cancel.clicked.connect( + lambda _checked=False, item=editor: self._cancel_profile(profile.key, item) + ) + self._new_config(profile.key) + self._build_tree(profile) + + def _new_config(self, project_key: str) -> ProfileConfigTab: + """Create the single reusable Config tab for one loaded project.""" + old = self.project_configs.get(project_key) + if old is not None: + old.deleteLater() + config = ProfileConfigTab(self.project, self.project_dimensions[project_key], self) + config.hide() + config.reset_builder(self.loaded_projects[project_key].profiles) + config.browse.clicked.connect(lambda: self._browse_profile(config)) + config.source_combo.currentIndexChanged.connect(lambda: self._select_profile_source(config)) + config.run.clicked.connect(lambda: self._run_configuration(config)) + self.project_configs[project_key] = config + return config + + def _build_tree(self, profile: GuiProjectProfile) -> None: + """Build one project/file/namelist branch with removal controls.""" + root = QTreeWidgetItem([profile.title, ""]) + root.setData(0, Qt.ItemDataRole.UserRole, ("project", profile.key, "", "")) + self.treeWidget_projectStructure.addTopLevelItem(root) + self._remove_button(root) + for file_profile in profile.profiles: + file_item = QTreeWidgetItem([file_profile.title, ""]) + file_item.setData( + 0, Qt.ItemDataRole.UserRole, ("profile", profile.key, file_profile.key, "") + ) + root.addChild(file_item) + self._remove_button(file_item) + for page in file_profile.pages: + child = QTreeWidgetItem([page.name, ""]) + child.setData( + 0, + Qt.ItemDataRole.UserRole, + ("namelist", profile.key, file_profile.key, page.key), + ) + file_item.addChild(child) + self._remove_button(child) + root.setExpanded(True) + self.project_items[profile.key] = root + + def _remove_button(self, item: QTreeWidgetItem) -> None: + """Install the tree action used to remove this item from the session.""" + button = QToolButton(self.treeWidget_projectStructure) + button.setText("×") + button.setAutoRaise(True) + button.clicked.connect(lambda: self._remove_tree_item(item)) + self.treeWidget_projectStructure.setItemWidget(item, 1, button) + + def _show_project(self, key: str) -> None: + """Show only the selected project's cached tabs.""" + if key not in self.loaded_projects: + return + self.active_project = key + self.editors = self.project_editors[key] + self.config_tab = self.project_configs[key] + with QSignalBlocker(self.tabs): + while self.tabs.count(): + widget = self.tabs.widget(0) + self.tabs.removeTab(0) + widget.hide() + self.tabs.addTab(self.config_tab, "⚙️") + for editor in self.editors.values(): + self.tabs.addTab(editor, editor.profile.title) + self.tabs.addTab(self.plus_tab, "+") + for side in (QTabBar.LeftSide, QTabBar.RightSide): + self.tabs.tabBar().setTabButton(self.tabs.indexOf(self.plus_tab), side, None) + self.tabs.setCurrentIndex(1 if self.editors else 0) + + def _tree_selected(self, current: QTreeWidgetItem | None, _previous: Any) -> None: + """Navigate from a tree identity to its project, file, or namelist page.""" + if current is None: + return + kind, project_key, profile_key, page_key = current.data(0, Qt.ItemDataRole.UserRole) + self._show_project(project_key) + if kind in {"profile", "namelist"}: + if kind == "profile": + current.setExpanded(True) + profile = next( + item + for item in self.loaded_projects[project_key].profiles + if item.key == profile_key + ) + editor = self._ensure_editor(project_key, profile) + self._insert_editor_tab(editor) + if kind == "namelist": + editor.select_page(page_key) + + def _tab_changed(self, index: int) -> None: + """Synchronize profile tabs with the tree and handle the trailing plus tab.""" + widget = self.tabs.widget(index) + if isinstance(widget, ProfileTab) and self.active_project is not None: + root = self.project_items[self.active_project] + for child_index in range(root.childCount()): + item = root.child(child_index) + if item.data(0, Qt.ItemDataRole.UserRole)[2] == widget.profile.key: + item.setExpanded(True) + with QSignalBlocker(self.treeWidget_projectStructure): + self.treeWidget_projectStructure.setCurrentItem(item) + break + elif widget is self.plus_tab and self.active_project is not None: + profile = self.loaded_projects[self.active_project] + config = self.project_configs[self.active_project] + self.config_tab = ( + self._new_config(self.active_project) + if self.tabs.indexOf(config) < 0 and self.config_tab is None + else config + ) + self.config_tab.reset_builder(profile.profiles) + if self.tabs.indexOf(self.config_tab) < 0: + self.tabs.insertTab(0, self.config_tab, "⚙️") + self.tabs.setCurrentWidget(self.config_tab) + + def _close_tab(self, index: int) -> None: + """Hide a Config or profile tab while keeping its cached state.""" + widget = self.tabs.widget(index) + if widget is None or widget is self.plus_tab: + return + if isinstance(widget, ProfileTab): + if ( + widget.is_dirty() + and QMessageBox.question( + self, "Unsaved changes", "Hide this profile with unsaved changes?" + ) + != QMessageBox.Yes + ): + return + with QSignalBlocker(self.tabs): + self.tabs.removeTab(index) + widget.hide() + if widget is self.config_tab: + self.config_tab = None + if self.tabs.count() == 1: + self._tab_changed(self.tabs.indexOf(self.plus_tab)) + + def _browse_profile(self, config: ProfileConfigTab) -> None: + """Add file profiles from a selected project-profile TOML file.""" + name, _ = QFileDialog.getOpenFileName( + self, "Load file profiles", str(self.project.root), "TOML files (*.toml)" + ) + if name: + try: + profiles = load_project_profile_file(self.project, Path(name)).profiles + for profile in profiles: + config.source_profiles[profile.key] = profile + data = ("profile", profile.key) + index = next( + ( + index + for index in range(config.source_combo.count()) + if config.source_combo.itemData(index) == data + ), + -1, + ) + if index < 0: + config.source_combo.addItem(profile.title, data) + if profiles: + config.source_combo.setCurrentIndex( + next( + index + for index in range(config.source_combo.count()) + if config.source_combo.itemData(index) == ("profile", profiles[0].key) + ) + ) + except (OSError, ValueError) as exc: + QMessageBox.critical(self, "Invalid profile", str(exc)) + + def _select_profile_source(self, config: ProfileConfigTab) -> None: + """Populate builder values from a reusable file profile.""" + data = config.source_combo.currentData() + if not data: + return + kind, value = data + config.source_profile = None + try: + if kind != "profile": + config.clear_builder() + return + config.source_profile = config.source_profiles[value] + profile = config.source_profile + config.profile_name.setText(profile.name) + config.default_filename.setText(profile.default_file) + selected = {page.key for page in profile.pages} + config.available_schemas.clear() + config.selected_schemas.clear() + for page in self.project.namelists: + target = ( + config.selected_schemas if page.key in selected else config.available_schemas + ) + target.addItem(self._schema_item(page.name, page.key)) + except (OSError, ValueError, KeyError) as exc: + QMessageBox.critical(self, "Invalid namelist", str(exc)) + + def _run_configuration(self, config: ProfileConfigTab) -> None: + """Apply dimensions and create or add the selected file profile.""" + if self.active_project is None: + return + try: + dimensions = _normalize_dimensions(config.dimensions(), self.project) + imported: Mapping[str, Any] | None = None + if ( + config.source_profile is None + and not config.profile_name.text().strip() + and not config.schema_keys() + ): + current = self.loaded_projects[self.active_project] + self.project_dimensions[current.key] = dimensions + self._rebuild_project_editors(current.key, fit_arrays=True) + return + profile = create_virtual_project( + self.project, + config.profile_name.text(), + config.default_filename.text(), + config.schema_keys(), + ).profiles[0] + source = config.source_profile + if source is not None: + selected = {page.key for page in profile.pages} + profile = replace( + profile, + title=source.title if profile.name == source.name else profile.name, + description=source.description, + required=tuple(name for name in source.required if name in selected), + ) + current = self.loaded_projects[self.active_project] + replace_index = next( + ( + index + for index, item in enumerate(current.profiles) + if source is not None and item.key == source.key + ), + None, + ) + for index, item in enumerate(current.profiles): + if index != replace_index and item.key == profile.key: + raise ValueError(f"file profile '{profile.name}' already exists") + if index != replace_index and item.default_file == profile.default_file: + raise ValueError(f"output file '{profile.default_file}' is already used") + if replace_index is None: + profiles = (*current.profiles, profile) + else: + profiles_list = list(current.profiles) + old = profiles_list[replace_index] + profiles_list[replace_index] = profile + profiles = tuple(profiles_list) + if old.default_file != profile.default_file: + old_path = _profile_path(self.project, old) + imported = self.project_editors[current.key][old_path].values() + profiles = ensure_dimension_sources(self.project, profiles) + current = self._customize(current) + current = replace(current, profiles=profiles) + save_project_profile(self.project, current) + self._replace_project(current) + self.project_dimensions[current.key] = dimensions + if imported is not None: + self.initial_values = { + **self.initial_values, + profile.key: {page.name: imported.get(page.name, {}) for page in profile.pages}, + } + self._rebuild_project_editors(current.key, fit_arrays=True) + config.reset_builder(current.profiles) + editor = self._ensure_editor(current.key, profile) + self._insert_editor_tab(editor) + except (OSError, ValueError, KeyError) as exc: + QMessageBox.critical(self, "Invalid configuration", str(exc)) + + def _customize(self, profile: GuiProjectProfile) -> GuiProjectProfile: + """Copy a configured project before a structural edit.""" + if profile.custom: + return profile + name, accepted = QInputDialog.getText( + self, "Custom project", "Save structural changes as", text=f"{profile.name}-custom" + ) + name = name.strip() + if not accepted: + raise ValueError("structural change cancelled") + if not name or not name.replace("-", "_").isalnum(): + raise ValueError("project name may contain only letters, numbers, '-' and '_'") + if name.lower() in self.loaded_projects and name.lower() != profile.key: + raise ValueError(f"project '{name}' is already loaded") + return replace(profile, name=name, key=name.lower(), title=name, source=None, custom=True) + + def _replace_project(self, profile: GuiProjectProfile) -> None: + """Replace the active project model and rebuild its tree branch.""" + old_key = self.active_project + if old_key is None: + return + item = self.project_items.pop(old_key) + index = self.treeWidget_projectStructure.indexOfTopLevelItem(item) + self.treeWidget_projectStructure.takeTopLevelItem(index) + editors = self.project_editors.pop(old_key) + config = self.project_configs.pop(old_key) + dimensions = self.project_dimensions.pop(old_key) + self.loaded_projects.pop(old_key) + self.loaded_projects[profile.key] = profile + self.project_editors[profile.key] = editors + self.project_configs[profile.key] = config + self.project_dimensions[profile.key] = dimensions + self.active_project = profile.key + self._build_tree(profile) + self.treeWidget_projectStructure.setCurrentItem(self.project_items[profile.key]) + + def _ensure_editor(self, project_key: str, profile: GuiProfile) -> ProfileTab: + """Return a cached editor, loading its direct namelist file once.""" + path = _profile_path(self.project, profile) + editors = self.project_editors[project_key] + if path in editors: + return editors[path] + dimensions = self.project_dimensions[project_key] + values = load_profile(self.project, profile, dimensions) + supplied = self.initial_values.get(profile.key, {}) + if supplied: + values = overlay_values( + values, + _normalize_profile_values( + supplied, profile, {**self.project.constants, **dimensions} + ), + ) + sources = dimension_source_values( + self.project, self.loaded_projects[project_key].profiles, dimensions + ) + values = overlay_values(values, sources.get(profile.key, {})) + editor = ProfileTab(self.project, profile, values, dimensions, self) + editor.hide() + editor.save.clicked.connect(lambda: self._save([editor])) + editor.cancel.clicked.connect(lambda: self._cancel_profile(project_key, editor)) + editors[path] = editor + return editor + + def _insert_editor_tab(self, editor: ProfileTab) -> None: + """Show and activate a cached file-profile editor.""" + index = self.tabs.indexOf(editor) + if index < 0: + index = self.tabs.indexOf(self.plus_tab) + self.tabs.insertTab(index, editor, editor.profile.title) + self.tabs.setCurrentWidget(editor) + + def _rebuild_project_editors(self, key: str, *, fit_arrays: bool = False) -> None: + """Rebuild a project's editors after dimensions or membership change.""" + old = self.project_editors[key] + old_values = {path: editor.values() for path, editor in old.items()} + sources = dimension_source_values( + self.project, + self.loaded_projects[key].profiles, + self.project_dimensions[key], + ) + for editor in old.values(): + self.tabs.removeTab(self.tabs.indexOf(editor)) + editor.deleteLater() + self.project_editors[key] = {} + for profile in self.loaded_projects[key].profiles: + path = _profile_path(self.project, profile) + if path in old_values: + previous = old[path] + values = overlay_values(old_values[path], sources.get(profile.key, {})) + editor = ProfileTab( + self.project, + profile, + values, + self.project_dimensions[key], + self, + fit_arrays=fit_arrays, + ) + editor.hide() + editor.save.clicked.connect(lambda _checked=False, item=editor: self._save([item])) + editor.cancel.clicked.connect( + lambda _checked=False, item=editor: self._cancel_profile(key, item) + ) + editor.saved_values = copy.deepcopy(previous.saved_values) + editor.saved_dimensions = dict(previous.saved_dimensions) + self.project_editors[key][path] = editor + else: + self._ensure_editor(key, profile) + self._show_project(key) + + def _cancel_profile(self, project_key: str, editor: ProfileTab) -> None: + """Replace one editor with its last successfully saved state.""" + path = _profile_path(self.project, editor.profile) + dimensions = self.project_dimensions[project_key] + replacement = ProfileTab( + self.project, + editor.profile, + editor.saved_values, + dimensions, + self, + fit_arrays=dimensions != editor.saved_dimensions, + ) + replacement.hide() + replacement.save.clicked.connect(lambda: self._save([replacement])) + replacement.cancel.clicked.connect(lambda: self._cancel_profile(project_key, replacement)) + self.project_editors[project_key][path] = replacement + self._show_project(project_key) + self._insert_editor_tab(replacement) + editor.setParent(None) + editor.deleteLater() + + def _save(self, editors: list[ProfileTab]) -> None: + """Validate and atomically save supplied profile editors.""" + try: + unique: dict[Path, tuple[ProfileTab, dict[str, Any]]] = {} + collected: list[tuple[ProfileTab, dict[str, Any]]] = [] + for editor in editors: + path = _profile_path(self.project, editor.profile) + values = editor.values() + collected.append((editor, values)) + previous = unique.get(path) + if previous and ( + previous[1] != values or previous[0].dimensions != editor.dimensions + ): + raise ValueError(f"loaded projects have conflicting edits for '{path.name}'") + unique[path] = (editor, values) + save_profiles( + self.project, + [(editor.profile, values, editor.dimensions) for editor, values in unique.values()], + ) + for editor, values in collected: + editor.mark_saved(values) + except (OSError, ValueError, KeyError) as exc: + QMessageBox.critical(self, "Save namelist", str(exc)) + + def _save_all(self) -> None: + """Save all loaded projects without writing duplicate files twice.""" + self._save( + [editor for editors in self.project_editors.values() for editor in editors.values()] + ) + + def _restore_all(self) -> None: + """Restore schema defaults in every editor of the selected project.""" + for editor in self.editors.values(): + editor.restore_all() + + def _remove_tree_item(self, item: QTreeWidgetItem) -> None: + """Confirm and apply a project, file-profile, or namelist removal.""" + kind, project_key, profile_key, page_key = item.data(0, Qt.ItemDataRole.UserRole) + if ( + QMessageBox.question(self, "Remove item", f"Remove '{item.text(0)}'?") + != QMessageBox.Yes + ): + return + try: + if kind == "project": + root = self.project_items.pop(project_key) + self.treeWidget_projectStructure.takeTopLevelItem( + self.treeWidget_projectStructure.indexOfTopLevelItem(root) + ) + self.loaded_projects.pop(project_key) + self.project_editors.pop(project_key) + self.project_configs.pop(project_key) + self.project_dimensions.pop(project_key) + if self.loaded_projects: + self._show_project(next(iter(self.loaded_projects))) + else: + while self.tabs.count(): + widget = self.tabs.widget(0) + self.tabs.removeTab(0) + widget.hide() + return + current = self._customize(self.loaded_projects[project_key]) + profiles = list(current.profiles) + index = next(i for i, profile in enumerate(profiles) if profile.key == profile_key) + if kind == "profile": + profiles.pop(index) + if not profiles: + raise ValueError("a saved project must contain at least one file profile") + else: + profile = profiles[index] + pages = tuple(page for page in profile.pages if page.key != page_key) + if not pages: + raise ValueError("a file profile must contain at least one namelist") + profiles[index] = replace( + profile, + pages=pages, + required=tuple(name for name in profile.required if name != page_key), + ) + # Validation intentionally occurs before replacing the visible tree. + checked = ensure_dimension_sources(self.project, profiles) + if checked != tuple(profiles): + raise ValueError("this item supplies a runtime dimension used by the project") + current = replace(current, profiles=tuple(profiles)) + save_project_profile(self.project, current) + self._replace_project(current) + self._rebuild_project_editors(current.key) + except (OSError, ValueError, KeyError) as exc: + QMessageBox.critical(self, "Remove item", str(exc)) + + +def launch_gui( + schemas_dir: Path | str | None = None, + output_dir: Path | str | None = None, + project_profiles: str + | Mapping[str, Mapping[str, list[str]]] + | Mapping[str, list[str]] + | None = None, + initial_values: Mapping[str, Any] | None = None, + initial_dimensions: Mapping[str, int] | None = None, +) -> int: + """Launch independently or reuse the caller's QApplication.""" + project = load_project(schemas_dir, output_dir, project_profiles) + application = QApplication.instance() + owns_application = application is None + if application is None: + application = QApplication(sys.argv[:1]) + application.setApplicationName("nml-tools") + dialog = ConfigurationDialog( + project, + initial_values=initial_values, + initial_dimensions=initial_dimensions, + preload_projects=bool(project_profiles), + ) + if not owns_application: + _exec(dialog) + return 0 + dialog.show() + method = getattr(application, "exec", None) or application.exec_ + return int(method()) diff --git a/src/nml_tools/gui/arrays.py b/src/nml_tools/gui/arrays.py new file mode 100644 index 0000000..4914fad --- /dev/null +++ b/src/nml_tools/gui/arrays.py @@ -0,0 +1,271 @@ +"""Array shape, label, and display-order helpers for the GUI.""" + +from __future__ import annotations + +import copy +import math +from collections.abc import Iterator, Mapping, Sequence +from typing import Any, cast + + +def resolve_shape( + schema: Mapping[str, Any], + sizes: Mapping[str, int], + existing: Any = None, +) -> tuple[int, ...]: + """Resolve ``x-fortran-shape`` using configured constants and dimensions.""" + raw = schema.get("x-fortran-shape") + dimensions = raw if isinstance(raw, list) else [raw] + existing_shape = _list_shape(existing) + result: list[int] = [] + for axis, dimension in enumerate(dimensions): + if isinstance(dimension, bool): + raise ValueError("array shape must not contain booleans") + if isinstance(dimension, int): + value = dimension + elif isinstance(dimension, str): + token = dimension.strip() + if token == ":": + value = existing_shape[axis] if axis < len(existing_shape) else 1 + else: + try: + value = int(token) + except ValueError as exc: + value = sizes.get(token.lower(), 0) + if not value: + raise ValueError(f"unknown array dimension '{dimension}'") from exc + else: + raise ValueError("array property must define 'x-fortran-shape'") + if value <= 0: + raise ValueError("array dimensions must be positive") + result.append(value) + if not result: + raise ValueError("array property must define 'x-fortran-shape'") + return tuple(result) + + +def flex_tail_dims(schema: Mapping[str, Any], rank: int) -> int: + """Return the validated number of flexible trailing dimensions.""" + raw = schema.get("x-fortran-flex-tail-dims", 0) + if isinstance(raw, bool) or not isinstance(raw, int): + raise ValueError("'x-fortran-flex-tail-dims' must be an integer") + if not 0 <= raw <= rank: + raise ValueError("'x-fortran-flex-tail-dims' must be between zero and the array rank") + return raw + + +def array_shape(value: Any) -> tuple[int, ...]: + """Return the non-empty rectangular shape of a array value.""" + shape = _list_shape(value) + if not shape: + raise ValueError("array values must be non-empty and rectangular") + return shape + + +def validate_array_shape( + schema: Mapping[str, Any], sizes: Mapping[str, int], value: Any +) -> tuple[int, ...]: + """Validate a saved array shape and return its actual shape.""" + actual = array_shape(value) + declared = resolve_shape(schema, sizes, value) + if len(actual) != len(declared): + raise ValueError(f"array rank {len(actual)} does not match declared rank {len(declared)}") + flexible = flex_tail_dims(schema, len(declared)) + fixed = len(declared) - flexible + if actual[:fixed] != declared[:fixed]: + raise ValueError(f"array shape {actual} does not match declared shape {declared}") + if flexible and any(actual[index] > declared[index] for index in range(fixed, len(declared))): + raise ValueError(f"array shape {actual} exceeds declared shape {declared}") + if not flexible and actual != declared: + raise ValueError(f"array shape {actual} does not match declared shape {declared}") + return actual + + +def axis_labels(schema: Mapping[str, Any], axis: int, extent: int) -> list[str] | None: + """Return labels for a one-based array *axis*, validating their count.""" + metadata = schema if "axes" in schema else schema.get("x-nml-tools-ui", {}) + if metadata is None: + return None + if not isinstance(metadata, Mapping): + raise ValueError("'x-nml-tools-ui' must be an object") + axes = metadata.get("axes", {}) + if not isinstance(axes, Mapping): + raise ValueError("'x-nml-tools-ui.axes' must be an object") + raw = axes.get(str(axis), axes.get(axis)) + if raw is None: + return None + if not isinstance(raw, Mapping): + raise ValueError(f"array UI axis {axis} must be an object") + title = raw.get("title") + if title is not None and not isinstance(title, str): + raise ValueError(f"array UI axis {axis} title must be a string") + labels = raw.get("labels") + template = raw.get("label-template") + if labels is not None and template is not None: + raise ValueError(f"array UI axis {axis} cannot define labels and label-template") + if labels is not None: + if not isinstance(labels, list) or not all(isinstance(item, str) for item in labels): + raise ValueError(f"array UI axis {axis} labels must be a list of strings") + if len(labels) != extent: + raise ValueError( + f"array UI axis {axis} defines {len(labels)} labels for extent {extent}" + ) + return list(labels) + if template is not None: + if not isinstance(template, str) or not template: + raise ValueError(f"array UI axis {axis} label-template must be a string") + try: + return [template.format(index=index) for index in range(1, extent + 1)] + except (KeyError, ValueError) as exc: + raise ValueError(f"array UI axis {axis} label-template must use '{{index}}'") from exc + return None + + +def table_axes(schema: Mapping[str, Any], rank: int) -> tuple[int, int] | None: + """Return zero-based ``(row, column)`` axes for a two-dimensional display.""" + metadata = schema if "table" in schema else schema.get("x-nml-tools-ui", {}) + if not isinstance(metadata, Mapping): + raise ValueError("'x-nml-tools-ui' must be an object") + table = metadata.get("table") + if table is None: + return (0, 1) if rank == 2 else None + if not isinstance(table, Mapping): + raise ValueError("'x-nml-tools-ui.table' must be an object") + row = table.get("row-axis") + column = table.get("column-axis") + if isinstance(row, bool) or not isinstance(row, int): + raise ValueError("array UI table row-axis must be an integer") + if isinstance(column, bool) or not isinstance(column, int): + raise ValueError("array UI table column-axis must be an integer") + if row == column or not 1 <= row <= rank or not 1 <= column <= rank: + raise ValueError("array UI table axes must be distinct valid one-based axes") + if rank != 2: + raise ValueError("array UI table orientation currently requires a rank-two array") + return row - 1, column - 1 + + +def display_array(value: Any, schema: Mapping[str, Any]) -> Any: + """Return a NumPy array ordered for the configured table display.""" + import numpy as np + + data = np.asarray(value) + if data.ndim == 1: + return data.reshape((1, data.shape[0])) + axes = table_axes(schema, data.ndim) + if axes is not None and axes != (0, 1): + return np.transpose(data, axes) + return data + + +def canonical_array(value: Any, schema: Mapping[str, Any], rank: int) -> list[Any]: + """Convert a displayed NumPy array back to canonical Fortran-axis order.""" + import numpy as np + + data = np.asarray(value) + if rank == 1: + data = data.reshape((-1,)) + else: + axes = table_axes(schema, rank) + if axes is not None and axes != (0, 1): + data = np.transpose(data, np.argsort(axes)) + return cast(list[Any], _native_value(data.tolist())) + + +def initial_array( + schema: Mapping[str, Any], + sizes: Mapping[str, int], + value: Any, + leaf_default: Any, + *, + strict: bool = False, + resize: bool = False, + defaults: list[Any] | None = None, +) -> list[Any]: + """Fit a saved/default/example value to the resolved canonical shape.""" + shape = resolve_shape(schema, sizes, value) + result = ( + copy.deepcopy(defaults) + if defaults is not None + else cast(list[Any], _filled(shape, leaf_default)) + ) + if value is None: + return result + if not isinstance(value, list): + if strict: + raise ValueError("saved array values must be arrays") + return cast(list[Any], _filled(shape, value)) + current_shape = _list_shape(value) + if strict: + validate_array_shape(schema, sizes, value) + if strict or resize or (current_shape and len(current_shape) == len(shape)): + if len(current_shape) != len(shape): + raise ValueError("saved array rank does not match its declared shape") + + def overlay(target: list[Any], source: list[Any]) -> None: + for index, item in enumerate(source[: len(target)]): + if isinstance(target[index], list): + overlay(target[index], item) + else: + target[index] = copy.deepcopy(item) + + overlay(result, value) + return result + flat = list(_flatten(value)) + if not flat: + return result + if len(shape) > 1 and len(flat) == shape[0]: + return _broadcast_first_axis(flat, shape) + if len(flat) == math.prod(shape): + return _reshape(flat, shape) + return _reshape([flat[index % len(flat)] for index in range(math.prod(shape))], shape) + + +def _broadcast_first_axis(values: list[Any], shape: tuple[int, ...]) -> list[Any]: + """Repeat each first-axis value across the remaining dimensions.""" + tail = shape[1:] + return [_filled(tail, value) for value in values] + + +def _filled(shape: Sequence[int], value: Any) -> Any: + """Build a nested array of the requested shape from one copied value.""" + if not shape: + return copy.deepcopy(value) + return [_filled(shape[1:], value) for _ in range(shape[0])] + + +def _reshape(values: Sequence[Any], shape: tuple[int, ...]) -> list[Any]: + """Reshape flat values into nested lists with the requested dimensions.""" + iterator = iter(values) + + def build(remaining: tuple[int, ...]) -> Any: + if len(remaining) == 1: + return [copy.deepcopy(next(iterator)) for _ in range(remaining[0])] + return [build(remaining[1:]) for _ in range(remaining[0])] + + return cast(list[Any], build(shape)) + + +def _flatten(value: Any) -> Iterator[Any]: + """Yield scalar leaves from a nested list in display order.""" + if isinstance(value, list): + for child in value: + yield from _flatten(child) + else: + yield value + + +def _list_shape(value: Any) -> tuple[int, ...]: + """Return a rectangular nested-list shape, or empty for invalid input.""" + if not isinstance(value, list) or not value: + return () + first = _list_shape(value[0]) + if any(_list_shape(item) != first for item in value[1:]): + return () + return (len(value), *first) + + +def _native_value(value: Any) -> Any: + """Convert NumPy scalar values to ordinary Python values recursively.""" + if isinstance(value, list): + return [_native_value(item) for item in value] + return value.item() if hasattr(value, "item") else value diff --git a/src/nml_tools/gui/fields.py b/src/nml_tools/gui/fields.py new file mode 100644 index 0000000..916e60b --- /dev/null +++ b/src/nml_tools/gui/fields.py @@ -0,0 +1,1735 @@ +# mypy: disable-error-code="attr-defined, import-not-found, import-untyped, misc" +"""Schema-driven Qt field widgets.""" + +from __future__ import annotations + +import copy +import math +import os +from collections.abc import Mapping +from itertools import product +from pathlib import Path +from typing import Any, cast + +from qtpy.QtCore import QDateTime, QEvent, Qt +from qtpy.QtGui import QKeySequence, QPalette +from qtpy.QtWidgets import ( + QApplication, + QCheckBox, + QComboBox, + QDateTimeEdit, + QFileDialog, + QFormLayout, + QGroupBox, + QHBoxLayout, + QHeaderView, + QInputDialog, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QSizePolicy, + QSpinBox, + QTableWidget, + QTableWidgetItem, + QWidget, +) + +from ..schema import DERIVED_REF_ORIGIN_KEY +from .arrays import ( + array_shape, + axis_labels, + canonical_array, + display_array, + initial_array, + resolve_shape, + table_axes, +) +from .model import GUI_REF_ORIGIN_KEY, MISSING, InputArray, overlay_values, suggestion +from .ui import load_ui + + +def _exec(dialog: Any) -> int: + """Execute a Qt dialog through the Qt 5 or Qt 6 method name.""" + method = getattr(dialog, "exec", None) + if method is None: + method = dialog.exec_ + return int(method()) + + +def _accepted(dialog: Any) -> int: + """Return the accepted-dialog result code across supported Qt bindings.""" + value = getattr(dialog, "Accepted", None) + value = value if value is not None else dialog.DialogCode.Accepted + return int(getattr(value, "value", value)) + + +def _derived_array_editor(editor_type: type[Any], parent: QWidget) -> Any: + """Create a guidata editor that correctly commits derived-type cells.""" + + # guidata's fixed-size record handler cannot commit (field, *indices) keys. + class DerivedArrayEditor(editor_type): + def accept(self) -> None: + """Commit structured edits before accepting the dialog.""" + for (name, *indices), value in self._data.current_changes.items(): + self._data.get_array()[name][tuple(indices)] = value + self._data.current_changes.clear() + super().accept() + + return DerivedArrayEditor(parent) + + +def _output_root(widget: QWidget) -> Path: + """Find the output directory attached to a widget's parent form.""" + current: QWidget | None = widget + while current is not None: + root = getattr(current, "output_root", None) + if isinstance(root, Path): + return root + current = current.parentWidget() + return Path.cwd() + + +def _relative_path(path: str, widget: QWidget) -> str: + """Express a selected file relative to the namelist output directory.""" + return Path(os.path.relpath(path, _output_root(widget))).as_posix() + + +def _palette_color(role: Any) -> str: + """Return a theme-aware palette color for lightweight hint styling.""" + return str(QApplication.palette().color(role).name()) + + +def _hint_style(default: bool) -> str: + """Return the distinct schema-hint style requested by the editor.""" + color = "#cc7070" if default else "#888888" + return f"color: {color}; font-style: italic;" + + +def _parse_date_time(value: Any) -> QDateTime: + """Parse supported namelist date-time strings into a Qt date-time.""" + text = str(value) + for pattern in ( + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd HH:mm", + "yyyy-MM-dd HH", + "yyyy-MM-dd", + "yyyy-MM-ddTHH:mm:ss", + "yyyy-MM-ddTHH:mm", + ): + result = QDateTime.fromString(text, pattern) + if result.isValid(): + return result + if not text: + return QDateTime.fromString("2000-01-01 00:00", "yyyy-MM-dd HH:mm") + raise ValueError(f"'{text}' is not a valid date-time") + + +def _add_path_array_controls(editor: Any, owner: QWidget) -> tuple[Any, Any, Any]: + """Add browse-and-apply controls below a guidata path array editor.""" + line = QLineEdit(editor) + browse = QPushButton("...", editor) + update = QPushButton("Update selected", editor) + controls = QHBoxLayout() + controls.addWidget(line, 1) + controls.addWidget(browse) + controls.addWidget(update) + editor.arraywidget.layout().addLayout(controls) + + def choose() -> None: + path, _ = QFileDialog.getOpenFileName(editor, "Select file", str(_output_root(owner))) + if path: + line.setText(_relative_path(path, owner)) + + def apply() -> None: + model = editor.arraywidget.model + for index in editor.arraywidget.view.selectedIndexes(): + model.setData(index, line.text()) + + browse.clicked.connect(choose) + update.clicked.connect(apply) + return line, browse, update + + +def _install_date_time_delegate(editor: Any) -> None: + """Use a date-time control when editing string array cells in guidata.""" + from guidata.widgets.arrayeditor.editorwidget import ArrayDelegate + + class DateTimeDelegate(ArrayDelegate): + def createEditor(self, parent: QWidget, option: Any, index: Any) -> QDateTimeEdit: + """Create the date-time control for one guidata cell.""" + control = QDateTimeEdit(parent) + control.setCalendarPopup(True) + control.setDisplayFormat("yyyy-MM-dd HH:mm") + return control + + def setEditorData(self, control: QDateTimeEdit, index: Any) -> None: + """Load the current cell text into the date-time control.""" + control.setDateTime(_parse_date_time(index.model().data(index))) + + def setModelData(self, control: QDateTimeEdit, model: Any, index: Any) -> None: + """Write the selected date-time back to the guidata model.""" + model.setData(index, control.dateTime().toString("yyyy-MM-dd HH:mm")) + + view = editor.arraywidget.view + view.setItemDelegate(DateTimeDelegate(view.model().get_array().dtype, view)) + + +def _seeded(schema: Mapping[str, Any]) -> bool: + """Return whether this property itself supplies an effective default.""" + return "default" in schema + + +def _contains_default(schema: Mapping[str, Any]) -> bool: + """Return whether a nested item can supply values after explicit reset.""" + if "default" in schema: + return True + properties = schema.get("properties", {}) + return isinstance(properties, Mapping) and any( + _contains_default(child) for child in properties.values() if isinstance(child, Mapping) + ) + + +class OptionalSpinBox(QSpinBox): + """Integer editor that can retain a visually empty value.""" + + def __init__(self, lower: int, upper: int, parent: QWidget) -> None: + self._empty = False + super().__init__(parent) + self.setRange(lower, upper) + self.lineEdit().textEdited.connect(self._text_edited) + self.editingFinished.connect(self._restore_empty) + + def _text_edited(self, text: str) -> None: + """Keep manually cleared input blank instead of restoring its old value.""" + self._empty = not text + + def _restore_empty(self) -> None: + """Keep Qt's input correction from restoring a cleared value.""" + if self._empty: + self.lineEdit().clear() + + def set_empty(self, hint: str = "") -> None: + """Display an empty value with an optional placeholder hint.""" + self._empty = True + self.lineEdit().setPlaceholderText(hint) + self.lineEdit().clear() + + def is_empty(self) -> bool: + """Return whether the editor currently represents no integer.""" + return self._empty + + def textFromValue(self, value: int) -> str: + """Keep Qt repaints from restoring a cleared value.""" + if getattr(self, "_empty", False): + return "" + return str(super().textFromValue(value)) + + def valueFromText(self, text: str) -> int: + """Retain the numeric value internally while its editor is empty.""" + return self.value() if not text.strip() else int(super().valueFromText(text)) + + def setValue(self, value: int) -> None: + """Replace an empty state with an explicit integer.""" + self._empty = False + super().setValue(value) + + def stepBy(self, steps: int) -> None: + """Make stepping away from an empty state explicit input.""" + self._empty = False + super().stepBy(steps) + + +class ScalarField(QWidget): + def __init__(self, schema: Mapping[str, Any], value: Any, parent: QWidget | None = None): + super().__init__(parent) + self.schema = schema + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + enum = schema.get("enum") + kind = schema.get("type") + control: QComboBox | QCheckBox | QSpinBox | QLineEdit + if isinstance(enum, list) and enum: + combo = QComboBox(self) + for item in enum: + combo.addItem(str(item), item) + control = combo + elif kind == "boolean": + control = QCheckBox(self) + elif kind == "integer": + lower = int(schema.get("minimum", schema.get("exclusiveMinimum", -2_147_483_648))) + upper = int(schema.get("maximum", schema.get("exclusiveMaximum", 2_147_483_647))) + lower += "exclusiveMinimum" in schema + upper -= "exclusiveMaximum" in schema + if -2_147_483_648 <= lower <= int(value) <= upper <= 2_147_483_647: + control = OptionalSpinBox(lower, upper, self) + else: + control = QLineEdit(self) + else: + control = QLineEdit(self) + self.control = control + self._hint_visible = False + examples = schema.get("examples") + self._hint = ( + (schema["default"], True) + if "default" in schema + else (examples[0], False) + if isinstance(examples, list) and examples + else None + ) + layout.addWidget(control) + control.installEventFilter(self) + self.set_value(value) + self.modified = False + signal = ( + control.textEdited + if isinstance(control, QLineEdit) + else control.toggled + if isinstance(control, QCheckBox) + else control.valueChanged + if isinstance(control, QSpinBox) + else control.currentIndexChanged + ) + signal.connect(self._mark_modified) + if isinstance(control, OptionalSpinBox): + control.lineEdit().textEdited.connect(self._mark_modified) + + def _mark_modified(self, *_args: Any) -> None: + """Mark a user choice and remove hint-only styling.""" + self.modified = True + empty = isinstance(self.control, QLineEdit) and not self.control.text() + empty = empty or isinstance(self.control, OptionalSpinBox) and self.control.is_empty() + if empty and self._hint: + self._apply_hint(*self._hint) + return + self.control.setStyleSheet("") + self._hint_visible = False + if isinstance(self.control, OptionalSpinBox): + self.control.lineEdit().setPlaceholderText("") + elif isinstance(self.control, QSpinBox): + self.control.setPrefix("") + if isinstance(self.control, QCheckBox): + self.control.setTristate(False) + self.control.setText("") + self.control.setToolTip(str(self._hint[0]) if self._hint else "") + + def show_hint(self, value: Any, default: bool) -> None: + """Display a default or example without treating it as user input.""" + self._hint = (value, default) + self._apply_hint(value, default) + self.modified = False + + def _apply_hint(self, value: Any, default: bool) -> None: + """Apply a remembered hint without changing modification state.""" + text = str(value) + if isinstance(self.control, QLineEdit): + self.control.clear() + self.control.setPlaceholderText(text) + elif isinstance(self.control, QComboBox): + self.control.insertItem(0, text, MISSING) + self.control.setCurrentIndex(0) + elif isinstance(self.control, QCheckBox): + self.control.setTristate(False) + self.control.setChecked(bool(value)) + self.control.setText(text) + self.control.setToolTip(text) + elif isinstance(self.control, OptionalSpinBox): + self.control.set_empty(text) + self.control.setStyleSheet(_hint_style(default)) + self._hint_visible = True + + def show_unset(self) -> None: + """Display an explicit unset state without producing a value.""" + self._hint = None + self._hint_visible = False + if isinstance(self.control, QLineEdit): + self.control.clear() + self.control.setPlaceholderText("…") + elif isinstance(self.control, QComboBox): + self.control.insertItem(0, "…", MISSING) + self.control.setCurrentIndex(0) + elif isinstance(self.control, QCheckBox): + self.control.setTristate(True) + self.control.setCheckState(Qt.PartiallyChecked) + elif isinstance(self.control, OptionalSpinBox): + self.control.set_empty() + self.modified = False + + def set_value(self, value: Any) -> None: + """Display a Python value in the scalar control.""" + self._hint_visible = False + self.control.setStyleSheet("") + if isinstance(self.control, QComboBox): + index = self.control.findData(value) + self.control.setCurrentIndex(max(index, 0)) + elif isinstance(self.control, QCheckBox): + self.control.setTristate(False) + self.control.setText("") + self.control.setToolTip("") + self.control.setChecked(bool(value)) + elif isinstance(self.control, OptionalSpinBox): + self.control.lineEdit().setPlaceholderText("") + self.control.setValue(value) + else: + self.control.setText(str(value)) + self.modified = True + + def eventFilter(self, watched: Any, event: Any) -> bool: + """Accept a visible schema hint when leaving its control with Tab.""" + if ( + watched is self.control + and event.type() == QEvent.KeyPress + and event.key() in {Qt.Key_Tab, Qt.Key_Backtab} + and self._hint_visible + and self._hint + ): + self.set_value(self._hint[0]) + return bool(super().eventFilter(watched, event)) + + def value(self) -> Any: + """Return the control value converted to its schema type.""" + if isinstance(self.control, QComboBox): + return self.control.currentData() + if isinstance(self.control, QCheckBox): + return self.control.isChecked() + if isinstance(self.control, OptionalSpinBox) and self.control.is_empty(): + if self._hint_visible and self._hint and self._hint[1]: + return self._hint[0] + return MISSING + if isinstance(self.control, QSpinBox): + return self.control.value() + text = self.control.text() + kind = self.schema.get("type") + try: + if kind == "integer": + return int(text) + if kind == "number": + value = float(text) + if not math.isfinite(value): + raise ValueError + return value + except ValueError as exc: + raise ValueError(f"'{text}' is not a valid {kind}") from exc + return text + + def reset(self, sizes: Mapping[str, int]) -> None: + """Restore the scalar's schema suggestion without marking an edit.""" + self.set_value(suggestion(self.schema, sizes)) + self.modified = False + + +class PathField(ScalarField): + def __init__(self, schema: Mapping[str, Any], value: Any, parent: QWidget | None = None): + super().__init__(schema, value, parent) + self.browse = QPushButton("...", self) + self.layout().addWidget(self.browse) + self.browse.clicked.connect(self._browse) + + def _browse(self) -> None: + """Select a file and display its path relative to the output directory.""" + path, _ = QFileDialog.getOpenFileName(self, "Select file", str(_output_root(self))) + if path: + self.set_value(_relative_path(path, self)) + + +class DateTimeField(ScalarField): + def __init__(self, schema: Mapping[str, Any], value: Any, parent: QWidget | None = None): + QWidget.__init__(self, parent) + self.schema = schema + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.control = QDateTimeEdit(self) + self.control.setCalendarPopup(True) + self.control.setDisplayFormat("yyyy-MM-dd HH:mm") + layout.addWidget(self.control) + self._hint = None + self._hint_visible = False + self.control.installEventFilter(self) + self.set_value(value) + self.modified = False + self.control.dateTimeChanged.connect(self._mark_modified) + + def show_hint(self, value: Any, default: bool) -> None: + """Style the displayed date-time as a non-literal hint.""" + self.set_value(value) + self._hint = (value, default) + self._hint_visible = True + self.control.setStyleSheet(_hint_style(default)) + self.control.setToolTip(str(value)) + self.modified = False + + def set_value(self, value: Any) -> None: + """Display a namelist date-time while preserving its original spelling.""" + self._hint_visible = False + self.control.setStyleSheet("") + self.control.setToolTip("") + self._original = str(value) + self.control.setDateTime(_parse_date_time(value)) + self.modified = True + + def value(self) -> str: + """Return the original or user-edited date-time string.""" + if not self.modified: + return self._original + return str(self.control.dateTime().toString("yyyy-MM-dd HH:mm")) + + def show_unset(self) -> None: + """Display an unset date-time using the control's special text.""" + self._hint = None + self._hint_visible = False + value = QDateTime.fromString("1900-01-01 00:00", "yyyy-MM-dd HH:mm") + self.control.setMinimumDateTime(value) + self.control.setSpecialValueText("…") + self.control.setDateTime(value) + self.modified = False + + +class ObjectField(QGroupBox): + def __init__( + self, + schema: Mapping[str, Any], + value: Any, + sizes: Mapping[str, int], + parent: QWidget | None = None, + *, + fit_arrays: bool = False, + ): + super().__init__(str(schema.get("x-fortran-type", "Derived value")), parent) + self.schema = schema + self.sizes = sizes + properties = schema.get("properties") + if not isinstance(properties, Mapping): + raise ValueError("derived field must define object 'properties'") + default = schema.get("default") + self.has_default = isinstance(default, Mapping) + self.base_default: dict[str, Any] = ( + copy.deepcopy(dict(default)) if isinstance(default, Mapping) else {} + ) + examples = schema.get("examples") + example = examples[0] if isinstance(examples, list) and examples else {} + hint_source: Mapping[str, Any] = self.base_default + if not self.has_default: + hint_source = example if isinstance(example, Mapping) else {} + source = copy.deepcopy(dict(hint_source)) + supplied = isinstance(value, Mapping) + if isinstance(value, Mapping): + source = overlay_values(source, value) + required = {item.lower() for item in schema.get("required", []) if isinstance(item, str)} + layout = QFormLayout(self) + self.rows: dict[str, FieldRow] = {} + for name, child in properties.items(): + if not isinstance(name, str) or not isinstance(child, Mapping): + continue + child_value = source.get(name, MISSING) if isinstance(source, Mapping) else MISSING + is_required = name.lower() in required + row = FieldRow( + name, + child, + child_value, + sizes, + self, + fit_arrays=fit_arrays, + required=is_required, + show_label=True, + ) + if not supplied and name in hint_source and isinstance(row.field, ScalarField): + row._provided = False + row._hint_override = copy.deepcopy(hint_source[name]) + row.field.show_hint(hint_source[name], self.has_default) + layout.addRow(row) + self.rows[name] = row + _align_field_rows(self.rows) + + def value(self) -> dict[str, Any]: + """Collect the present component values from this derived object.""" + result: dict[str, Any] = copy.deepcopy(dict(self.base_default)) + for name, row in self.rows.items(): + value = row.value() + if value is not MISSING: + result[name] = value + return result + + def reset(self, sizes: Mapping[str, int]) -> None: + """Restore defaults for every component of the derived object.""" + examples = self.schema.get("examples") + example = examples[0] if isinstance(examples, list) and examples else {} + hints = self.base_default if self.has_default else example + for name, row in self.rows.items(): + if isinstance(hints, Mapping) and name in hints: + row.set_value(hints[name], sizes) + row._provided = False + row._hint_override = copy.deepcopy(hints[name]) + if isinstance(row.field, ScalarField): + row.field.show_hint(hints[name], self.has_default) + else: + row.reset(sizes) + + +class InlineArrayTable(QWidget): + """Inline editor for one- and two-dimensional schema arrays.""" + + def __init__( + self, + name: str, + schema: Mapping[str, Any], + value: list[Any], + assigned: set[tuple[int, ...]], + hinted: set[tuple[int, ...]], + sizes: Mapping[str, int], + required: bool, + parent: QWidget, + ) -> None: + super().__init__(parent) + load_ui("array_table.ui", self) + self.name, self.schema, self.sizes = name, schema, sizes + self.items = schema["items"] + self._value = copy.deepcopy(value) + self._assigned = set(assigned) + self._hinted = set(hinted) + self.cells: dict[tuple[int, ...], FieldRow] = {} + self.titleLabel.setText(_field_label(name, schema, required)) + description = schema.get("description") + has_description = isinstance(description, str) and bool(description.strip()) + self.infoButton.setVisible(has_description) + self.infoButton.setStyleSheet(f"color: {_palette_color(QPalette.Link)}; font-weight: bold;") + if has_description: + self.infoButton.clicked.connect( + lambda: QMessageBox.information( + self, str(schema.get("title", name)), str(description).strip() + ) + ) + self.tableWidget.installEventFilter(self) + self._populate() + + @property + def modified(self) -> bool: + """Return whether any cell has been explicitly edited.""" + return any(_field_modified(cell.field) for cell in self.cells.values()) + + def _populate(self) -> None: + """Create cell editors using schema axis labels when available.""" + shape = array_shape(self._value) + rank = len(shape) + table = self.tableWidget + positions: list[tuple[tuple[int, ...], int, int]] + if rank == 1: + table.setRowCount(1) + table.setColumnCount(shape[0]) + labels = axis_labels(self.schema, 1, shape[0]) or [ + str(i) for i in range(1, shape[0] + 1) + ] + table.setHorizontalHeaderLabels(labels) + table.verticalHeader().setVisible(False) + positions = [((index,), 0, index - 1) for index in range(1, shape[0] + 1)] + else: + axes = table_axes(self.schema, 2) or (0, 1) + shown = display_array(self._value, self.schema) + table.setRowCount(shown.shape[0]) + table.setColumnCount(shown.shape[1]) + table.setHorizontalHeaderLabels( + axis_labels(self.schema, axes[1] + 1, shown.shape[1]) + or [str(i) for i in range(1, shown.shape[1] + 1)] + ) + table.setVerticalHeaderLabels( + axis_labels(self.schema, axes[0] + 1, shown.shape[0]) + or [str(i) for i in range(1, shown.shape[0] + 1)] + ) + positions = [] + for row in range(shown.shape[0]): + for column in range(shown.shape[1]): + displayed = [row, column] + canonical = [0, 0] + canonical[axes[0]], canonical[axes[1]] = displayed + positions.append((tuple(index + 1 for index in canonical), row, column)) + defaulted = "default" in self.schema + fallback = suggestion(self.schema, self.sizes) + has_hint = defaulted or bool(self.schema.get("examples")) + for indices, row, column in positions: + raw = _nested_get(self._value, tuple(index - 1 for index in indices)) + cell = FieldRow(self.name, self.items, raw, self.sizes, self) + if has_hint and isinstance(cell.field, ScalarField): + cell.field._hint = ( + _nested_get(fallback, tuple(index - 1 for index in indices)), + defaulted, + ) + if indices in self._hinted or indices not in self._assigned: + cell._provided = False + if isinstance(cell.field, ScalarField): + if indices in self._hinted: + cell.field.show_hint(raw, defaulted) + else: + cell.field.show_unset() + table.setCellWidget(row, column, cell) + self.cells[indices] = cell + _configure_table_columns(table) + _fit_table_height(table) + + def value(self) -> InputArray: + """Return dense values and only defaulted, loaded, or edited indices.""" + result = copy.deepcopy(self._value) + assigned = set(self._assigned) + for indices, cell in self.cells.items(): + modified = _field_modified(cell.field) + value = cell.field.value() if modified else MISSING + if modified and value is MISSING: + assigned.discard(indices) + continue + if not modified: + value = _nested_get(self._value, tuple(index - 1 for index in indices)) + target = _nested_get(result, tuple(index - 1 for index in indices[:-1])) + target[indices[-1] - 1] = value + if modified: + assigned.add(indices) + return InputArray(result, assigned) + + def eventFilter(self, watched: Any, event: Any) -> bool: + """Provide spreadsheet-style copy and paste for inline cells.""" + if watched is self.tableWidget and event.type() == QEvent.KeyPress: + if event.matches(QKeySequence.Copy): + indexes = [ + index + for index in self.tableWidget.selectedIndexes() + if self.tableWidget.cellWidget(index.row(), index.column()) + ] + if indexes: + rows = sorted({index.row() for index in indexes}) + columns = sorted({index.column() for index in indexes}) + QApplication.clipboard().setText( + "\n".join( + "\t".join( + str(self.tableWidget.cellWidget(row, column).field.value()) + for column in columns + if isinstance(self.tableWidget.cellWidget(row, column), FieldRow) + ) + for row in rows + ) + ) + return True + if event.matches(QKeySequence.Paste): + current = self.tableWidget.currentIndex() + for row_offset, line in enumerate(QApplication.clipboard().text().splitlines()): + for column_offset, text in enumerate(line.split("\t")): + cell = self.tableWidget.cellWidget( + current.row() + row_offset, current.column() + column_offset + ) + if isinstance(cell, FieldRow): + cell.set_value(_parse_text(text, self.items), self.sizes) + return True + return bool(super().eventFilter(watched, event)) + + +class ArrayField(QWidget): + def __init__( + self, + name: str, + schema: Mapping[str, Any], + value: Any, + sizes: Mapping[str, int], + parent: QWidget | None = None, + *, + fit_existing: bool = False, + required: bool = False, + ): + super().__init__(parent) + self.name = name + self.schema = schema + self.sizes = sizes + self.required = required + saved = value is not MISSING + candidate = suggestion(schema, sizes) if not saved else value + items = schema.get("items") + if not isinstance(items, Mapping): + raise ValueError("array field must define object 'items'") + self.items = items + self._value = initial_array( + schema, + sizes, + candidate, + suggestion(items, sizes), + strict=saved and not fit_existing, + resize=saved and fit_existing, + defaults=suggestion( + {**schema, "x-fortran-shape": list(resolve_shape(schema, sizes, candidate))}, sizes + ) + if saved and fit_existing + else None, + ) + shape = array_shape(self._value) + positions = set(product(*(range(1, size + 1) for size in shape))) + self.assigned = ( + set(value.assigned) if isinstance(value, InputArray) else positions if saved else set() + ) & positions + if _seeded(schema): + self.assigned.update(positions) + self.hinted = ( + positions if not saved and ("default" in schema or schema.get("examples")) else set() + ) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.summary = QLabel(self) + self.inline: Any = None + self.table_editor: InlineArrayTable | None = None + self.button = QPushButton("Edit array…", self) + self.button.clicked.connect(self._edit) + layout.addWidget(self.summary, 1) + layout.addWidget(self.button) + self._update_summary() + + def value(self) -> InputArray: + """Return dense array values plus the indices selected for output.""" + if self.table_editor is not None: + return self.table_editor.value() + if self.inline is not None: + value = self.inline.value() + if getattr(self.inline, "modified", False) or isinstance(value, dict) and value: + self.assigned.add((1,)) + return InputArray([value], set(self.assigned)) + return InputArray(copy.deepcopy(self._value), set(self.assigned)) + + def reset(self, sizes: Mapping[str, int]) -> None: + """Restore the array suggestion and its assigned indices.""" + self.sizes = sizes + self._value = suggestion(self.schema, sizes) + shape = array_shape(self._value) + self.assigned = ( + set(product(*(range(1, size + 1) for size in shape))) + if _seeded(self.schema) or _contains_default(self.items) + else set() + ) + self.hinted = ( + set(product(*(range(1, size + 1) for size in shape))) + if "default" in self.schema or self.schema.get("examples") + else set() + ) + self._update_summary() + + def _update_summary(self) -> None: + """Show the array shape or an inline editor for a single element.""" + shape = array_shape(self._value) + if self.table_editor is not None: + self.layout().removeWidget(self.table_editor) + self.table_editor.deleteLater() + self.table_editor = None + self.inline = None + if len(shape) <= 2: + self.table_editor = InlineArrayTable( + self.name, + self.schema, + self._value, + self.assigned, + self.hinted, + self.sizes, + self.required, + self, + ) + self.layout().insertWidget(0, self.table_editor, 1) + if shape == (1,): + self.inline = next(iter(self.table_editor.cells.values())).field + self.summary.setVisible(self.table_editor is None) + raw = self.schema.get("x-fortran-shape") + deferred = raw == ":" or isinstance(raw, list) and ":" in raw + self.button.setVisible(self.table_editor is None or deferred) + self.button.setText("Resize…" if self.table_editor is not None else "Edit array…") + self.summary.setText("×".join(str(value) for value in shape)) + + def _edit(self) -> None: + """Edit the array in guidata and record changed Fortran indices.""" + try: + if self.table_editor is not None: + text, accepted = QInputDialog.getText( + self, + "Resize array", + "Shape", + text=",".join(map(str, array_shape(self._value))), + ) + if not accepted: + return + shape = tuple(int(part.strip()) for part in text.split(",")) + if len(shape) != len(array_shape(self._value)) or any(size <= 0 for size in shape): + raise ValueError("shape must contain one positive size per array axis") + old = self.value() + self._value = initial_array( + {**self.schema, "x-fortran-shape": list(shape)}, + self.sizes, + old, + suggestion(self.items, self.sizes), + resize=True, + ) + self.assigned = { + indices + for indices in old.assigned + if all(index <= size for index, size in zip(indices, shape)) + } + self.hinted = set() + self._update_summary() + return + import numpy as np + from guidata.widgets.arrayeditor import ArrayEditor + + before = self.value() + self._value = before + rank = len(resolve_shape(self.schema, self.sizes, self._value)) + derived = self.items.get("type") == "object" + canonical = self._structured_array(np) if derived else self._intrinsic_array(np) + displayed = display_array(canonical, self.schema) + xlabels, ylabels = self._display_labels(displayed.shape, rank) + editor = _derived_array_editor(ArrayEditor, self) if derived else ArrayEditor(self) + raw_shape = self.schema.get("x-fortran-shape") + deferred = raw_shape == ":" or (isinstance(raw_shape, list) and ":" in raw_shape) + if not editor.setup_and_check( + displayed, + str(self.schema.get("title", self.name)), + xlabels=xlabels, + ylabels=ylabels, + variable_size=deferred, + ): + return + if self.items.get("format") == "file-path" and not derived: + _add_path_array_controls(editor, self) + if self.items.get("format") == "date-time" and not derived: + _install_date_time_delegate(editor) + if _exec(editor) != _accepted(editor): + return + edited = editor.get_value() + if derived: + self._value = self._objects_from_structured(edited, rank, np) + else: + self._value = canonical_array(edited, self.schema, rank) + shape = array_shape(self._value) + old_shape = array_shape(before) + self.assigned = { + indices + for indices in self.assigned + if all(index <= size for index, size in zip(indices, shape)) + } + for indices in product(*(range(size) for size in shape)): + if any(index >= size for index, size in zip(indices, old_shape)) or _nested_get( + before, indices + ) != _nested_get(self._value, indices): + self.assigned.add(tuple(index + 1 for index in indices)) + self._update_summary() + except (ImportError, RuntimeError, TypeError, ValueError) as exc: + QMessageBox.critical(self, "Array editor", str(exc)) + + def _intrinsic_array(self, np: Any) -> Any: + """Convert intrinsic array values to a typed NumPy array.""" + kind = self.items.get("type") + if not isinstance(kind, str): + raise ValueError("array items must define a string type") + dtype = { + "integer": np.int64, + "number": np.float64, + "boolean": np.bool_, + "string": "U1024", + }.get(kind) + if dtype is None: + raise ValueError(f"unsupported array item type '{kind}'") + return np.asarray(self._value, dtype=dtype) + + def _structured_array(self, np: Any) -> Any: + """Convert derived-type values to a structured NumPy array.""" + properties = self.items.get("properties") + if not isinstance(properties, Mapping): + raise ValueError("derived array items must define properties") + fields = [] + for name, child in properties.items(): + if not isinstance(name, str) or not isinstance(child, Mapping): + continue + child_kind = child.get("type") + if not isinstance(child_kind, str): + raise ValueError("derived components must define a string type") + dtype = { + "integer": np.int64, + "number": np.float64, + "boolean": np.bool_, + "string": "U1024", + }.get(child_kind) + if dtype is None: + raise ValueError(f"unsupported derived component type '{child_kind}'") + title = str(child.get("title", name)) + fields.append((name, dtype) if title == name else ((title, name), dtype)) + shape = array_shape(self._value) + result = np.empty(shape, dtype=np.dtype(fields)) + defaults = suggestion(self.items, self.sizes) + for index in np.ndindex(shape): + item = _nested_get(self._value, index) + if not isinstance(item, Mapping): + item = defaults + for name in result.dtype.names or (): + result[index][name] = item.get(name, defaults[name]) + return result + + def _objects_from_structured(self, value: Any, rank: int, np: Any) -> list[Any]: + """Convert an edited structured array back to derived-value mappings.""" + data = np.asarray(value) + if rank == 1: + data = data.reshape((-1,)) + else: + axes = table_axes(self.schema, rank) + if axes is not None and axes != (0, 1): + data = np.transpose(data, np.argsort(axes)) + edited = _structured_to_objects(data) + defaults = suggestion(self.items, self.sizes) + return cast(list[Any], _preserve_omissions(edited, self._value, defaults)) + + def _display_labels( + self, displayed_shape: tuple[int, ...], rank: int + ) -> tuple[list[str] | None, list[str] | None]: + """Return configured column and row labels for the displayed array.""" + if rank == 1: + return axis_labels(self.schema, 1, displayed_shape[1]), None + if rank != 2: + return None, None + axes = table_axes(self.schema, rank) or (0, 1) + return ( + axis_labels(self.schema, axes[1] + 1, displayed_shape[1]), + axis_labels(self.schema, axes[0] + 1, displayed_shape[0]), + ) + + +class FieldRow(QWidget): + def __init__( + self, + name: str, + schema: Mapping[str, Any], + value: Any, + sizes: Mapping[str, int], + parent: QWidget | None = None, + *, + fit_arrays: bool = False, + required: bool = False, + show_label: bool = False, + ): + super().__init__(parent) + load_ui("field_row.ui", self) + self.name = name + self.schema = schema + self.sizes = sizes + self.required = required or "default" in schema + self.show_label = show_label + self._provided = value is not MISSING + self._hint_override = MISSING + self.fieldLabel.setText(_field_label(name, schema, self.required)) + self.fieldLabel.setVisible(show_label) + initial = value + if value is MISSING and schema.get("type") not in {"array", "object"}: + initial = suggestion(schema, sizes) + self.field = _field_widget( + name, schema, initial, sizes, self, fit_arrays=fit_arrays, required=self.required + ) + description = schema.get("description") + if isinstance(description, str): + description = description.strip() + self.field.setToolTip(description) + self.infoButton.clicked.connect( + lambda: QMessageBox.information(self, str(schema.get("title", name)), description) + ) + self.infoButton.setStyleSheet(f"color: {_palette_color(QPalette.Link)}; font-weight: bold;") + self._update_decorations() + self.editorLayout.addWidget(self.field) + if isinstance(self.field, ScalarField) and (value is MISSING or value == ""): + examples = schema.get("examples") + if "default" in schema: + self.field.show_hint(schema["default"], True) + elif isinstance(examples, list) and examples: + self.field.show_hint(examples[0], False) + else: + self.field.show_unset() + + def _update_decorations(self) -> None: + """Keep labels and information actions outside inline array tables.""" + inline = isinstance(self.field, ArrayField) and self.field.table_editor is not None + self.fieldLabel.setVisible(self.show_label and not inline) + description = str(self.schema.get("description", "")).strip() + self.infoButton.setVisible(self.show_label and not inline and bool(description)) + + def value(self) -> Any: + """Return the row value, omitting untouched fields without seed data.""" + untouched = not self._provided and not getattr(self.field, "modified", False) + if untouched and isinstance(self.field, ScalarField): + if self._hint_override is not MISSING: + return copy.deepcopy(self._hint_override) + if "default" in self.schema: + return copy.deepcopy(self.schema["default"]) + if self.required: + raise ValueError(f"required field '{self.name}' has no value") + return MISSING + if ( + not self._provided + and isinstance(self.field, ObjectField) + and not _field_modified(self.field) + ): + if "default" in self.schema: + return copy.deepcopy(self.schema["default"]) + if not self.required: + return MISSING + if self.schema.get("examples"): + raise ValueError(f"required field '{self.name}' has no value") + if ( + isinstance(self.field, ScalarField) + and isinstance(self.field.control, QLineEdit) + and not self.field.control.text() + ): + if "default" in self.schema: + return copy.deepcopy(self.schema["default"]) + if self.required: + raise ValueError(f"required field '{self.name}' has no value") + return MISSING + value = self.field.value() + if value is MISSING: + if "default" in self.schema: + return copy.deepcopy(self.schema["default"]) + if self.required: + raise ValueError(f"required field '{self.name}' has no value") + return MISSING + if isinstance(value, InputArray) and not value.assigned: + if self.required: + raise ValueError(f"required field '{self.name}' has no value") + return MISSING + if isinstance(self.field, ObjectField) and not value: + if self.required: + raise ValueError(f"required field '{self.name}' has no value") + return MISSING + return value + + def reset(self, sizes: Mapping[str, int]) -> None: + """Replace this row with a fresh field using schema suggestions.""" + if isinstance(self.field, ArrayField): + self._provided = False + self.sizes = sizes + self.field.reset(sizes) + self._update_decorations() + return + self.set_value(MISSING, sizes) + + def set_value(self, value: Any, sizes: Mapping[str, int]) -> None: + """Rebuild the row field for a supplied value and array dimensions.""" + self._provided = value is not MISSING + self._hint_override = MISSING + self.sizes = sizes + initial = value + if value is MISSING and self.schema.get("type") not in {"array", "object"}: + initial = suggestion(self.schema, sizes) + replacement = _field_widget( + self.name, self.schema, initial, sizes, self, required=self.required + ) + replacement.setToolTip(self.field.toolTip()) + self.editorLayout.replaceWidget(self.field, replacement) + self.field.deleteLater() + self.field = replacement + self._update_decorations() + if isinstance(self.field, ScalarField) and (value is MISSING or value == ""): + examples = self.schema.get("examples") + if "default" in self.schema: + self.field.show_hint(self.schema["default"], True) + elif isinstance(examples, list) and examples: + self.field.show_hint(examples[0], False) + else: + self.field.show_unset() + elif isinstance(self.field, ScalarField): + self.field.modified = True + + +def _align_field_rows(rows: Mapping[str, FieldRow]) -> None: + """Align editors by giving visible labels the widest label width.""" + labels = [row.fieldLabel for row in rows.values() if not row.fieldLabel.isHidden()] + if labels: + width = max(label.sizeHint().width() for label in labels) + for label in labels: + label.setFixedWidth(width) + + +def _fit_table_height(table: QTableWidget) -> None: + """Fit short tables to their rows and cap taller tables for scrolling.""" + table.resizeRowsToContents() + content_height = ( + table.horizontalHeader().height() + + sum(table.rowHeight(row) for row in range(table.rowCount())) + + 2 * table.frameWidth() + + 2 + ) + table.setFixedHeight(min(400, content_height) + table.horizontalScrollBar().sizeHint().height()) + + +def _configure_table_columns(table: QTableWidget, label_column: int | None = None) -> None: + """Fit table columns initially while keeping them manually resizable.""" + header = cast(QHeaderView, table.horizontalHeader()) + header.setMinimumSectionSize(88) + header.setSectionResizeMode(QHeaderView.Interactive) + header.sectionHandleDoubleClicked.connect(table.resizeColumnToContents) + table.resizeColumnsToContents() + for column in range(table.columnCount()): + table.setColumnWidth(column, max(88, table.columnWidth(column))) + if label_column is not None: + table.setColumnWidth(label_column, max(180, table.columnWidth(label_column))) + + +class DerivedTable(QTableWidget): + """Same-reference objects as rows, reusing the existing component editors.""" + + def __init__( + self, + schemas: Mapping[str, Mapping[str, Any]], + values: Mapping[str, Any], + sizes: Mapping[str, int], + required: set[str], + parent: QWidget, + *, + fit_arrays: bool, + ) -> None: + super().__init__(parent) + self.schemas, self.sizes, self.required = schemas, sizes, required + self.data: dict[str, Any] = {} + self.objects: dict[tuple[str, tuple[int, ...]], ObjectField] = {} + self.present: set[str] = set() + self.example_only: set[str] = set() + self.table_rows: list[tuple[str, tuple[int, ...], Mapping[str, Any]]] = [] + first = next(iter(schemas.values())) + first = first["items"] if first["type"] == "array" else first + columns = list(first["properties"]) + self.setColumnCount(len(columns) + 1) + self.setHorizontalHeaderLabels(["Namelist Property", *columns]) + self.verticalHeader().setVisible(False) + for name, schema in schemas.items(): + value = values.get(name, MISSING) + is_array = schema["type"] == "array" + item = schema["items"] if is_array else schema + if is_array: + dense = initial_array( + schema, + sizes, + suggestion(schema, sizes) if value is MISSING else value, + suggestion(item, sizes), + strict=value is not MISSING and not fit_arrays, + resize=value is not MISSING and fit_arrays, + defaults=suggestion( + {**schema, "x-fortran-shape": list(resolve_shape(schema, sizes, value))}, + sizes, + ) + if value is not MISSING and fit_arrays + else None, + ) + shape = array_shape(dense) + positions = set(product(*(range(1, n + 1) for n in shape))) + source_positions = ( + set(value.assigned) + if isinstance(value, InputArray) + else positions + if value is not MISSING + else set() + ) & positions + seeded = _seeded(schema) or (name.lower() in required and _contains_default(item)) + if seeded: + source_positions.update(positions) + self.data[name] = InputArray(dense, source_positions) + else: + self.data[name] = {} if value is MISSING else value + if value is not MISSING or _seeded(schema) or name.lower() in required: + self.present.add(name) + if value is MISSING and not _seeded(schema) and schema.get("examples"): + self.example_only.add(name) + shape = array_shape(self.data[name]) if is_array else () + for indices in product(*(range(n) for n in shape)): + source = ( + _nested_get(self.data[name], indices) + if not is_array or tuple(index + 1 for index in indices) in source_positions + else MISSING + ) + obj = ObjectField(item, source, sizes, self) + obj.hide() + row = self.rowCount() + self.insertRow(row) + suffix = "(" + ",".join(str(i + 1) for i in indices) + ")" if indices else "" + self.setCellWidget( + row, + 0, + _property_widget(name + suffix, schema, name.lower() in required, self), + ) + for column, component in enumerate(columns): + component_row = obj.rows[component] + component_row.fieldLabel.hide() + self.setCellWidget(row, column + 1, component_row) + label = QTableWidgetItem( + component + (" *" if component in item.get("required", []) else "") + ) + label.setToolTip( + str(item["properties"][component].get("description", component)) + ) + self.setHorizontalHeaderItem(column + 1, label) + self.objects[name, indices] = obj + self.table_rows.append((name, indices, item)) + _configure_table_columns(self, 0) + _fit_table_height(self) + self.installEventFilter(self) + + def values(self) -> dict[str, Any]: + """Collect each table row into scalar or indexed derived values.""" + result = copy.deepcopy(self.data) + for (name, indices), obj in self.objects.items(): + value = obj.value() + if ( + not indices + and name in self.example_only + and name.lower() in self.required + and not _field_modified(obj) + ): + raise ValueError(f"required field '{name}' has no value") + if indices: + _nested_get(result[name], indices[:-1])[indices[-1]] = value + if _field_modified(obj): + result[name].assigned.add(tuple(index + 1 for index in indices)) + else: + if name in self.present or _field_modified(obj): + result[name] = value + else: + result.pop(name, None) + for name, value in result.items(): + if ( + name.lower() in self.required + and isinstance(value, InputArray) + and not value.assigned + ): + raise ValueError(f"required field '{name}' has no value") + return result + + def reset(self) -> None: + """Restore defaults and assigned positions for all derived rows.""" + self.present = { + name + for name, schema in self.schemas.items() + if _seeded(schema) or name.lower() in self.required + } + for name, data in self.data.items(): + if isinstance(data, InputArray): + shape = array_shape(data) + data.assigned = ( + set(product(*(range(1, n + 1) for n in shape))) + if _seeded(self.schemas[name]) + or name.lower() in self.required + and _contains_default(self.schemas[name]["items"]) + else set() + ) + for obj in self.objects.values(): + obj.reset(self.sizes) + + def eventFilter(self, watched: Any, event: Any) -> bool: + """Provide copy and paste for inline derived-type component cells.""" + if watched is self and event.type() == QEvent.KeyPress: + if event.matches(QKeySequence.Copy): + _copy_table_selection(self) + return True + if event.matches(QKeySequence.Paste): + current = self.currentIndex() + columns = list(next(iter(self.table_rows))[2]["properties"]) + for row_offset, line in enumerate(QApplication.clipboard().text().splitlines()): + row = current.row() + row_offset + if row >= self.rowCount(): + break + schema = self.table_rows[row][2] + for column_offset, text in enumerate(line.split("\t")): + column = current.column() + column_offset + component = column - 1 + cell = self.cellWidget(row, column) + if isinstance(cell, FieldRow) and 0 <= component < len(columns): + cell.set_value( + _parse_text(text, schema["properties"][columns[component]]), + self.sizes, + ) + return True + return bool(super().eventFilter(watched, event)) + + +class ReferencedArrayTable(QTableWidget): + """One row per referenced one-dimensional parameter array.""" + + def __init__( + self, + schemas: Mapping[str, Mapping[str, Any]], + values: Mapping[str, Any], + sizes: Mapping[str, int], + required: set[str], + parent: QWidget, + *, + fit_arrays: bool, + ) -> None: + super().__init__(parent) + self.schemas, self.sizes, self.required = schemas, sizes, required + self.rows: dict[str, list[FieldRow]] = {} + self.data: dict[str, list[Any]] = {} + self.assigned: dict[str, set[tuple[int, ...]]] = {} + self.hinted: dict[str, set[tuple[int, ...]]] = {} + self.row_names = list(schemas) + first = next(iter(schemas.values())) + shape = resolve_shape(first, sizes) + if len(shape) != 1: + raise ValueError("referenced parameter arrays must be one-dimensional") + size = shape[0] + self.setRowCount(len(schemas)) + self.setColumnCount(size + 1) + self.setHorizontalHeaderLabels( + [ + "Namelist Property", + *(axis_labels(first, 1, size) or [str(index) for index in range(1, size + 1)]), + ] + ) + self.verticalHeader().setVisible(False) + for row_index, (name, schema) in enumerate(schemas.items()): + item = schema["items"] + raw = values.get(name, MISSING) + saved = raw is not MISSING + fallback = suggestion(schema, sizes) + dense = initial_array( + schema, + sizes, + raw if saved else fallback, + suggestion(item, sizes), + strict=saved and not fit_arrays, + resize=saved and fit_arrays, + ) + positions: set[tuple[int, ...]] = {(index,) for index in range(1, size + 1)} + assigned: set[tuple[int, ...]] = ( + set(raw.assigned) if isinstance(raw, InputArray) else positions if saved else set() + ) & positions + if _seeded(schema): + assigned.update(positions) + hinted: set[tuple[int, ...]] = ( + positions + if not saved and ("default" in schema or schema.get("examples")) + else set() + ) + self.data[name] = dense + self.assigned[name] = assigned + self.hinted[name] = hinted + self.setCellWidget( + row_index, + 0, + _property_widget(name, schema, name.lower() in required, self), + ) + cells = [] + for index in range(size): + position = (index + 1,) + cell = FieldRow(name, item, dense[index], sizes, self) + if "default" in schema or schema.get("examples"): + cell.field._hint = (fallback[index], "default" in schema) + if position in hinted or position not in assigned: + cell._provided = False + if isinstance(cell.field, ScalarField): + if position in hinted: + cell.field.show_hint(dense[index], "default" in schema) + else: + cell.field.show_unset() + self.setCellWidget(row_index, index + 1, cell) + cells.append(cell) + self.rows[name] = cells + _configure_table_columns(self, 0) + _fit_table_height(self) + self.installEventFilter(self) + + def values(self) -> dict[str, InputArray]: + """Collect non-empty table cells as explicitly assigned arrays.""" + result = {} + for name, cells in self.rows.items(): + values = copy.deepcopy(self.data[name]) + assigned = set(self.assigned[name]) + for index, cell in enumerate(cells, 1): + if _field_modified(cell.field): + value = cell.field.value() + if value is MISSING: + assigned.discard((index,)) + continue + values[index - 1] = value + assigned.add((index,)) + if assigned: + result[name] = InputArray(values, assigned) + elif name.lower() in self.required: + raise ValueError(f"required field '{name}' has no value") + return result + + def reset(self) -> None: + """Restore defaults in every referenced-array table row.""" + for name, cells in self.rows.items(): + schema = self.schemas[name] + defaults = suggestion(schema, self.sizes) + positions: set[tuple[int, ...]] = {(index,) for index in range(1, len(cells) + 1)} + self.assigned[name] = ( + positions if _seeded(schema) or _contains_default(schema["items"]) else set() + ) + self.hinted[name] = ( + positions if "default" in schema or schema.get("examples") else set() + ) + self.data[name] = copy.deepcopy(defaults) + for index, cell in enumerate(cells): + position = (index + 1,) + cell.set_value(defaults[index], self.sizes) + cell._provided = False + if isinstance(cell.field, ScalarField): + if position in self.hinted[name]: + cell.field.show_hint(defaults[index], "default" in schema) + elif position not in self.assigned[name]: + cell.field.show_unset() + + def eventFilter(self, watched: Any, event: Any) -> bool: + """Provide copy and paste for grouped one-dimensional arrays.""" + if watched is self and event.type() == QEvent.KeyPress: + if event.matches(QKeySequence.Copy): + _copy_table_selection(self) + return True + if event.matches(QKeySequence.Paste): + current = self.currentIndex() + for row_offset, line in enumerate(QApplication.clipboard().text().splitlines()): + row = current.row() + row_offset + if row >= self.rowCount(): + break + item_schema = self.schemas[self.row_names[row]]["items"] + for column_offset, text in enumerate(line.split("\t")): + cell = self.cellWidget(row, current.column() + column_offset) + if isinstance(cell, FieldRow): + cell.set_value(_parse_text(text, item_schema), self.sizes) + return True + return bool(super().eventFilter(watched, event)) + + +class NamelistForm(QWidget): + """Editable form for one namelist schema.""" + + def __init__( + self, + schema: Mapping[str, Any], + values: Mapping[str, Any] | None, + sizes: Mapping[str, int], + parent: QWidget | None = None, + *, + fit_arrays: bool = False, + output_root: Path | None = None, + read_only: set[str] | None = None, + ): + super().__init__(parent) + self.schema = schema + self.sizes = sizes + self.output_root = (output_root or Path.cwd()).resolve() + properties = schema.get("properties") + if not isinstance(properties, Mapping): + raise ValueError("namelist schema must define object 'properties'") + source = values or {} + required = {item.lower() for item in schema.get("required", []) if isinstance(item, str)} + required.update( + name.lower() + for name, child in properties.items() + if isinstance(name, str) and isinstance(child, Mapping) and "default" in child + ) + layout = QFormLayout(self) + layout.setFormAlignment(Qt.AlignLeft | Qt.AlignTop) + self.rows: dict[str, FieldRow] = {} + self.tables: list[DerivedTable | ReferencedArrayTable] = [] + groups: dict[tuple[str, ...], dict[str, Mapping[str, Any]]] = {} + identities = {} + for name, child in properties.items(): + item = child.get("items", {}) if child.get("type") == "array" else child + origin = item.get(DERIVED_REF_ORIGIN_KEY) if item.get("type") == "object" else None + identity = tuple(origin["identity"]) if origin else () + if identity and child.get("type") == "array" and len(resolve_shape(child, sizes)) > 2: + identity = () + if child.get("type") == "array" and child.get(GUI_REF_ORIGIN_KEY): + shape = resolve_shape(child, sizes) + if len(shape) == 1: + identity = ("array", str(child[GUI_REF_ORIGIN_KEY])) + if identity: + identities[name] = identity + groups.setdefault(identity, {})[name] = child + for name, child in properties.items(): + if not isinstance(name, str) or not isinstance(child, Mapping): + continue + is_required = name.lower() in required + identity = identities.get(name, ()) + children = groups.get(identity) + if children: + if name == next(iter(children)): + table_type = ( + ReferencedArrayTable + if child.get("type") == "array" and child["items"].get("type") != "object" + else DerivedTable + ) + table = table_type( + children, source, sizes, required, self, fit_arrays=fit_arrays + ) + layout.addRow(table) + layout.setAlignment(table, Qt.AlignTop) + self.tables.append(table) + continue + row = FieldRow( + name, + child, + source.get(name, MISSING), + sizes, + self, + fit_arrays=fit_arrays, + required=is_required, + show_label=True, + ) + if name.lower() in (read_only or set()): + row.field.setEnabled(False) + layout.addRow(row) + self.rows[name] = row + _align_field_rows(self.rows) + spacer = QWidget(self) + spacer.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) + layout.addRow(spacer) + + def values(self) -> dict[str, Any]: + """Collect fields and grouped tables in original schema order.""" + result: dict[str, Any] = {} + for name, row in self.rows.items(): + value = row.value() + if value is not MISSING: + result[name] = value + for table in self.tables: + result.update(table.values()) + return {name: result[name] for name in self.schema["properties"] if name in result} + + def reset(self) -> None: + """Restore every standalone field and grouped table on the form.""" + for row in self.rows.values(): + row.reset(self.sizes) + for table in self.tables: + table.reset() + + +def _field_widget( + name: str, + schema: Mapping[str, Any], + value: Any, + sizes: Mapping[str, int], + parent: QWidget, + *, + fit_arrays: bool = False, + required: bool = False, +) -> Any: + """Choose the editor widget that matches a property's schema type.""" + kind = schema.get("type") + if kind == "array": + return ArrayField( + name, schema, value, sizes, parent, fit_existing=fit_arrays, required=required + ) + if kind == "object": + return ObjectField(schema, value, sizes, parent, fit_arrays=fit_arrays) + if kind == "string" and schema.get("format") == "file-path": + return PathField(schema, value, parent) + if kind == "string" and schema.get("format") == "date-time": + return DateTimeField(schema, value, parent) + return ScalarField(schema, value, parent) + + +def _field_label(name: str, schema: Mapping[str, Any], required: bool) -> str: + """Build a field label from its title, name, and required marker.""" + title = schema.get("title") + label = f"{title} ({name})" if isinstance(title, str) and title.strip() else name + return f"{label} *" if required else label + + +def _field_modified(field: Any) -> bool: + """Return whether an editor or one of its component editors changed.""" + if isinstance(field, ObjectField): + return any(_field_modified(row.field) for row in field.rows.values()) + if isinstance(field, ArrayField): + return bool(field.value().assigned) + return bool(getattr(field, "modified", False)) + + +def _parse_text(text: str, schema: Mapping[str, Any]) -> Any: + """Convert pasted table text to the intrinsic schema type.""" + kind = schema.get("type") + if kind == "integer": + return int(text) + if kind == "number": + value = float(text) + if not math.isfinite(value): + raise ValueError(f"'{text}' is not a valid number") + return value + if kind == "boolean": + normalized = text.strip().lower().strip(".") + if normalized not in {"true", "false"}: + raise ValueError(f"'{text}' is not a valid boolean") + return normalized == "true" + return text + + +def _copy_table_selection(table: QTableWidget) -> None: + """Copy selected editor-cell values as tab-separated text.""" + indexes = [ + index + for index in table.selectedIndexes() + if isinstance(table.cellWidget(index.row(), index.column()), FieldRow) + ] + if not indexes: + return + rows = sorted({index.row() for index in indexes}) + columns = sorted({index.column() for index in indexes}) + QApplication.clipboard().setText( + "\n".join( + "\t".join( + str(table.cellWidget(row, column).field.value()) + for column in columns + if isinstance(table.cellWidget(row, column), FieldRow) + ) + for row in rows + ) + ) + + +def _property_widget( + name: str, schema: Mapping[str, Any], required: bool, parent: QWidget +) -> QWidget: + """Build the Designer property label and information action for a table.""" + widget = QWidget(parent) + load_ui("field_row.ui", widget) + widget.fieldLabel.setText(name + (" *" if required else "")) + widget.editorHost.hide() + description = str(schema.get("description", "")).strip() + widget.infoButton.setVisible(bool(description)) + widget.infoButton.setStyleSheet(f"color: {_palette_color(QPalette.Link)}; font-weight: bold;") + if description: + widget.infoButton.clicked.connect( + lambda: QMessageBox.information(widget, str(schema.get("title", name)), description) + ) + return widget + + +def _nested_get(value: Any, indices: tuple[int, ...]) -> Any: + """Retrieve a value from nested lists using zero-based indices.""" + for index in indices: + value = value[index] + return value + + +def _structured_to_objects(data: Any) -> list[Any]: + """Convert a structured NumPy array into nested component mappings.""" + names = data.dtype.names or () + + def build(axis: int, prefix: tuple[int, ...]) -> Any: + if axis == data.ndim: + record = data[prefix] + return {name: _numpy_scalar(record[name]) for name in names} + return [build(axis + 1, (*prefix, index)) for index in range(data.shape[axis])] + + return cast(list[Any], build(0, ())) + + +def _numpy_scalar(value: Any) -> Any: + """Convert a NumPy scalar to its native Python equivalent.""" + return value.item() if hasattr(value, "item") else value + + +def _preserve_omissions(edited: Any, original: Any, defaults: Any) -> Any: + """Drop untouched default components that were absent from saved input.""" + if isinstance(edited, list) and isinstance(original, list): + return [ + _preserve_omissions(item, original[index], defaults) if index < len(original) else item + for index, item in enumerate(edited) + ] + if isinstance(edited, Mapping) and isinstance(original, Mapping): + return { + name: value + for name, value in edited.items() + if name in original or not isinstance(defaults, Mapping) or value != defaults.get(name) + } + return edited diff --git a/src/nml_tools/gui/model.py b/src/nml_tools/gui/model.py new file mode 100644 index 0000000..608bdd3 --- /dev/null +++ b/src/nml_tools/gui/model.py @@ -0,0 +1,1153 @@ +"""Qt-independent project loading and direct namelist persistence.""" + +from __future__ import annotations + +import copy +import math +import os +import tempfile +from collections.abc import Iterable +from dataclasses import dataclass, field, replace +from itertools import product +from pathlib import Path +from typing import Any, Mapping, cast + +import click +import yaml + +from .._dimensions import DimensionSource, infer_runtime_dimensions +from .._namelist_eval import EvaluatedGroup, LeafState, _expand_values, _value_count, evaluate_group +from .._namelist_parser import RawValue, ScalarSelector, parse_namelist +from ..cli import ( + _load_config_checked, + _load_config_metadata, + _load_constants, + _load_dimensions, + _load_namelist_registry, + _load_toml, + _namelist_registry_by_key, +) +from ..codegen_fortran import _format_scalar_default +from ..schema import SchemaResolver +from ..validate import _scalar_constraints, _validate_scalar_value, validate_schema_defaults +from .arrays import initial_array, resolve_shape + +MISSING = object() +GUI_REF_ORIGIN_KEY = "_nml_tools_gui_ref_origin" + + +def _mark_referenced_arrays(loaded: Iterable[Any]) -> None: + """Keep array-reference identity needed only by the GUI table layout.""" + for namelist in loaded: + path = namelist.entry["schema"] + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + raw_properties = raw.get("properties", {}) if isinstance(raw, Mapping) else {} + for name, child in namelist.schema.get("properties", {}).items(): + source = raw_properties.get(name, {}) + if child.get("type") == "array" and isinstance(source.get("$ref"), str): + child[GUI_REF_ORIGIN_KEY] = source["$ref"] + + +class InputArray(list[Any]): + """Dense editor values with the indices selected for namelist output.""" + + def __init__(self, values: list[Any], assigned: set[tuple[int, ...]]): + super().__init__(values) + self.assigned = assigned + + +def suggestion(schema: Mapping[str, Any], sizes: Mapping[str, int]) -> Any: + """Return the deterministic editable value used for an unset schema field.""" + examples = schema.get("examples") + if "default" in schema: + candidate = copy.deepcopy(schema["default"]) + elif isinstance(examples, list) and examples: + candidate = copy.deepcopy(examples[0]) + else: + candidate = MISSING + + kind = schema.get("type") + if kind == "array": + items = schema.get("items") + if not isinstance(items, Mapping): + raise ValueError("array field must define object 'items'") + leaf = suggestion(items, sizes) + if "default" in schema and isinstance(candidate, list): + shape = resolve_shape(schema, sizes) + count = math.prod(shape) + if schema.get("x-fortran-default-repeat"): + candidate = [candidate[index % len(candidate)] for index in range(count)] + elif "x-fortran-default-pad" in schema: + pad = schema["x-fortran-default-pad"] + pad = pad if isinstance(pad, list) else [pad] + candidate += [pad[index % len(pad)] for index in range(count - len(candidate))] + if len(candidate) != count: + raise ValueError("array default does not match its configured dimensions") + result = _filled(shape, leaf) + for index, coordinates in enumerate(product(*(range(size) for size in shape))): + if schema.get("x-fortran-default-order", "F").upper() == "F": + index = sum(c * math.prod(shape[:axis]) for axis, c in enumerate(coordinates)) + target = result + for coordinate in coordinates[:-1]: + target = target[coordinate] + target[coordinates[-1]] = copy.deepcopy(candidate[index]) + return result + if "default" in items: + candidate = MISSING + return initial_array(schema, sizes, None if candidate is MISSING else candidate, leaf) + if kind == "object": + raw = candidate if isinstance(candidate, Mapping) else {} + properties = schema.get("properties") + if not isinstance(properties, Mapping): + raise ValueError("derived field must define object 'properties'") + return { + name: copy.deepcopy(raw[name]) if name in raw else suggestion(child, sizes) + for name, child in properties.items() + if isinstance(name, str) and isinstance(child, Mapping) + } + if candidate is not MISSING: + return candidate + enum = schema.get("enum") + if isinstance(enum, list) and enum: + return copy.deepcopy(enum[0]) + if kind == "boolean": + return False + if kind == "integer": + minimum = schema.get("minimum") + return int(minimum) if isinstance(minimum, int) and not isinstance(minimum, bool) else 0 + if kind == "number": + minimum = schema.get("minimum") + return float(minimum) if isinstance(minimum, (int, float)) else 0.0 + if kind == "string": + return "" + raise ValueError(f"unsupported schema type '{kind}'") + + +@dataclass(frozen=True) +class NamelistPage: + """A configured namelist and its resolved schema.""" + + name: str + key: str + schema: dict[str, Any] + + +@dataclass(frozen=True) +class GuiProfile: + """An ordered file profile presented by the GUI.""" + + name: str + key: str + title: str + description: str | None + default_file: str + pages: tuple[NamelistPage, ...] + required: tuple[str, ...] = () + + +@dataclass(frozen=True) +class GuiProjectProfile: + """An ordered set of namelist files edited as one project.""" + + name: str + key: str + title: str + description: str | None + profiles: tuple[GuiProfile, ...] + source: Path | None = None + custom: bool = False + + +@dataclass(frozen=True) +class GuiProject: + """Resolved nml-tools project data needed by the GUI.""" + + root: Path + constants: dict[str, int] + default_dimensions: dict[str, int] + profiles: tuple[GuiProfile, ...] + output_dir: Path | None = None + namelists: tuple[NamelistPage, ...] = () + project_profiles: tuple[GuiProjectProfile, ...] = () + dimension_sources: dict[str, DimensionSource] = field(default_factory=dict) + + @property + def output_root(self) -> Path: + """Return the directory used for namelist output.""" + return self.output_dir or self.root + + def profile(self, key: str) -> GuiProfile: + """Find a configured profile by its case-insensitive key.""" + for profile in self.profiles: + if profile.key == key.lower(): + return profile + raise KeyError(key) + + def project_profile(self, key: str) -> GuiProjectProfile: + """Find a project profile by its case-insensitive name.""" + for profile in self.project_profiles: + if profile.key == key.lower(): + return profile + raise KeyError(key) + + +def load_project( + schemas_dir: Path | str | None = None, + output_dir: Path | str | None = None, + project_profiles: str + | Mapping[str, Mapping[str, list[str]]] + | Mapping[str, list[str]] + | None = None, + *, + file_profiles: Mapping[str, list[str]] | None = None, +) -> GuiProject: + """Load schemas and selected project profiles from the project config.""" + if file_profiles is not None: + if project_profiles is not None: + raise ValueError("pass project_profiles or file_profiles, not both") + project_profiles = file_profiles + root = Path.cwd() if schemas_dir is None else Path(schemas_dir) + root = root.resolve() + output_root = root if output_dir is None else Path(output_dir).resolve() + config_path = root / "nml-config.toml" + if not config_path.is_file(): + raise RuntimeError(f"nml-config.toml was not found in {root}") + + try: + config, resolved_path = _load_config_checked(config_path) + constants, _ = _load_constants(config) + dimensions, _ = _load_dimensions(config, constants) + loaded = _load_namelist_registry(config, resolved_path.parent, SchemaResolver()) + _mark_referenced_arrays(loaded) + registry = _namelist_registry_by_key(loaded) + metadata = _load_config_metadata(config, registry) + configured_profiles = metadata.file_profiles + except click.ClickException as exc: + raise RuntimeError(exc.format_message()) from exc + except (OSError, ValueError) as exc: + raise RuntimeError(str(exc)) from exc + + namelists = tuple(NamelistPage(item.name, item.key, item.schema) for item in loaded) + pages_by_key = {page.key: page for page in namelists} + profiles: list[GuiProfile] = [] + output_paths: dict[Path, str] = {} + for configured in configured_profiles.values(): + target = (output_root / configured.default_file).resolve() + try: + target.relative_to(output_root) + except ValueError as exc: + raise RuntimeError( + f"file profile '{configured.name}' writes outside the output directory" + ) from exc + previous = output_paths.get(target) + if previous is not None: + raise RuntimeError( + f"file profiles '{previous}' and '{configured.name}' both write {target}" + ) + output_paths[target] = configured.name + pages = tuple(pages_by_key[key] for key in configured.namelists) + profiles.append( + GuiProfile( + name=configured.name, + key=configured.key, + title=configured.title or configured.name, + description=configured.description, + default_file=configured.default_file, + pages=pages, + required=tuple(configured.required), + ) + ) + + by_key = {profile.key: profile for profile in profiles} + configured_projects = tuple( + GuiProjectProfile( + configured.name, + configured.key, + configured.title or configured.name, + configured.description, + tuple(by_key[key] for key in configured.file_profiles), + config_path, + ) + for configured in metadata.project_profiles.values() + ) + if not configured_projects: + configured_projects = ( + GuiProjectProfile("default", "default", "Default", None, tuple(profiles), config_path), + ) + project = GuiProject( + root, + constants, + dimensions, + tuple(profiles), + output_root, + namelists, + configured_projects, + metadata.dimension_sources, + ) + if project_profiles is None or project_profiles == {}: + return project + if isinstance(project_profiles, str): + try: + selected_project = project.project_profile(project_profiles) + except KeyError as exc: + raise ValueError(f"unknown project profile '{project_profiles}'") from exc + return replace( + project, + profiles=selected_project.profiles, + project_profiles=(selected_project,), + ) + if not isinstance(project_profiles, Mapping): + raise ValueError("project_profiles must be a name or mapping") + if project_profiles and all(isinstance(value, list) for value in project_profiles.values()): + selected_profiles = _select_profiles( + project, cast(Mapping[str, list[str]], project_profiles) + ) + implicit = GuiProjectProfile( + "default", "default", "Default", None, selected_profiles, custom=True + ) + return replace(project, profiles=selected_profiles, project_profiles=(implicit,)) + if not all(isinstance(value, Mapping) for value in project_profiles.values()): + raise ValueError("project_profiles must map project names to file-profile mappings") + selected_projects: list[GuiProjectProfile] = [] + union: dict[str, GuiProfile] = {} + for project_name, selection in project_profiles.items(): + if not isinstance(project_name, str): + raise ValueError("project profile names must be strings") + configured_project = next( + (item for item in project.project_profiles if item.key == project_name.lower()), None + ) + available = configured_project.profiles if configured_project else project.profiles + base = replace(project, profiles=available) + chosen = ( + available + if not selection + else _select_profiles(base, cast(Mapping[str, list[str]], selection)) + ) + item = GuiProjectProfile( + configured_project.name if configured_project else project_name, + project_name.lower(), + configured_project.title if configured_project else project_name, + configured_project.description if configured_project else None, + chosen, + configured_project.source if configured_project else None, + configured_project is None, + ) + selected_projects.append(item) + union.update((profile.key, profile) for profile in chosen) + return replace( + project, profiles=tuple(union.values()), project_profiles=tuple(selected_projects) + ) + + +def _select_profiles( + project: GuiProject, file_profiles: Mapping[str, list[str]] +) -> tuple[GuiProfile, ...]: + """Apply the legacy file-profile selection shape.""" + selected: dict[str, GuiProfile] = {} + for name, names in file_profiles.items(): + if not isinstance(name, str) or not isinstance(names, list): + raise ValueError("file_profiles must map profile names to lists of namelist names") + try: + profile = project.profile(name) + except KeyError as exc: + raise ValueError(f"unknown file profile '{name}'") from exc + if profile.key in selected: + raise ValueError(f"duplicate file profile '{name}'") + if not all(isinstance(item, str) for item in names): + raise ValueError(f"namelist names for '{name}' must be strings") + keys = {item.lower() for item in names} + unknown = keys - {page.key for page in profile.pages} + if unknown: + raise ValueError(f"profile '{name}' has no namelists: {', '.join(sorted(unknown))}") + selected[profile.key] = replace( + profile, pages=tuple(page for page in profile.pages if not keys or page.key in keys) + ) + return tuple(selected[p.key] for p in project.profiles if p.key in selected) + + +def create_virtual_project( + project: GuiProject, + name: str, + default_file: str, + namelist_keys: Iterable[str], +) -> GuiProject: + """Return one user-defined file profile using registered namelists.""" + if not isinstance(name, str): + raise ValueError("file profile name must be a string") + clean_name = name.strip() + if not clean_name: + raise ValueError("file profile name must not be empty") + if not isinstance(default_file, str): + raise ValueError("default file name must be a string") + clean_default = default_file.strip() + if not clean_default: + raise ValueError("default file name must not be empty") + + target = (project.output_root / clean_default).resolve() + try: + relative = target.relative_to(project.output_root) + except ValueError as exc: + raise ValueError("default file must be inside the output directory") from exc + if target == project.output_root: + raise ValueError("default file must name a file") + + available = {page.key: page for page in project.namelists} + selected: list[NamelistPage] = [] + seen: set[str] = set() + for raw_key in namelist_keys: + if not isinstance(raw_key, str) or not raw_key.strip(): + raise ValueError("selected namelist names must be non-empty strings") + key = raw_key.lower() + if key in seen: + raise ValueError(f"selected namelist '{raw_key}' is duplicated") + seen.add(key) + page = available.get(key) + if page is None: + raise ValueError(f"selected namelist '{raw_key}' is unknown") + selected.append(page) + if not selected: + raise ValueError("at least one namelist schema must be selected") + + profile = GuiProfile( + name=clean_name, + key=clean_name.lower(), + title=clean_name, + description=None, + default_file=str(relative), + pages=tuple(selected), + ) + return replace(project, profiles=(profile,)) + + +def discover_project_files(project: GuiProject) -> tuple[Path, ...]: + """Find additional project-profile TOML files in input and output roots.""" + config = (project.root / "nml-config.toml").resolve() + paths = { + path.resolve() + for root in {project.root, project.output_root} + for path in root.glob("*.toml") + if path.name != "nml-config.toml" and path.resolve() != config + } + return tuple(sorted(paths)) + + +def load_project_profile_file(project: GuiProject, path: Path) -> GuiProjectProfile: + """Load one custom project profile against the configured schema registry.""" + data = _load_toml(path) + available = {profile.key: profile for profile in project.profiles} + pages = {page.key: page for page in project.namelists} + raw_files = data.get("file_profiles", []) + if not isinstance(raw_files, list): + raise ValueError("'file_profiles' must be a list") + custom_keys: set[str] = set() + for entry in raw_files: + if not isinstance(entry, Mapping): + raise ValueError("each file profile must be a table") + name = entry.get("name") + default_file = entry.get("default_file") + members = entry.get("namelists") + if not isinstance(name, str) or not name.strip(): + raise ValueError("file profile must define a non-empty name") + if not isinstance(default_file, str) or not default_file.strip(): + raise ValueError(f"file profile '{name}' must define default_file") + if ( + not isinstance(members, list) + or not members + or not all(isinstance(item, str) for item in members) + ): + raise ValueError(f"file profile '{name}' must define namelist names") + unknown = [item for item in members if item.lower() not in pages] + if unknown: + raise ValueError(f"file profile '{name}' has unknown namelists: {', '.join(unknown)}") + key = name.lower() + if key in custom_keys: + raise ValueError(f"file profile '{name}' is defined more than once") + custom_keys.add(key) + member_keys = [item.lower() for item in members] + if len(member_keys) != len(set(member_keys)): + raise ValueError(f"file profile '{name}' repeats a namelist") + required = entry.get("required", []) + if not isinstance(required, list) or not all(isinstance(item, str) for item in required): + raise ValueError(f"file profile '{name}' required members must be strings") + required_keys = [item.lower() for item in required] + if len(required_keys) != len(set(required_keys)) or not set(required_keys) <= set( + member_keys + ): + raise ValueError(f"file profile '{name}' has invalid required namelists") + title = _optional_toml_text(entry, "title", f"file profile '{name}'") + description = _optional_toml_text(entry, "description", f"file profile '{name}'") + profile = GuiProfile( + name.strip(), + key, + title or name, + description, + default_file.strip(), + tuple(pages[item] for item in member_keys), + tuple(required_keys), + ) + _profile_path(project, profile) + available[key] = profile + raw_projects = data.get("project_profiles") + if not isinstance(raw_projects, list) or len(raw_projects) != 1: + raise ValueError("additional TOML must define exactly one project profile") + entry = raw_projects[0] + if not isinstance(entry, Mapping): + raise ValueError("project profile must be a table") + name = entry.get("name") + members = entry.get("file_profiles") + if not isinstance(name, str) or not name.strip(): + raise ValueError("project profile must define a non-empty name") + if ( + not isinstance(members, list) + or not members + or not all(isinstance(item, str) for item in members) + ): + raise ValueError(f"project profile '{name}' must define file profile names") + member_keys = [item.lower() for item in members] + if len(member_keys) != len(set(member_keys)): + raise ValueError(f"project profile '{name}' repeats a file profile") + unknown = [item for item in members if item.lower() not in available] + if unknown: + raise ValueError(f"project profile '{name}' has unknown files: {', '.join(unknown)}") + title = _optional_toml_text(entry, "title", f"project profile '{name}'") + description = _optional_toml_text(entry, "description", f"project profile '{name}'") + return GuiProjectProfile( + name.strip(), + name.lower(), + title or name, + description, + tuple(available[item] for item in member_keys), + path.resolve(), + True, + ) + + +def _optional_toml_text(entry: Mapping[str, Any], key: str, label: str) -> str | None: + """Read one optional non-empty string from a custom project file.""" + value = entry.get(key) + if value is None: + return None + if not isinstance(value, str): + raise ValueError(f"{label} '{key}' must be a string") + return value.strip() or None + + +def save_project_profile(project: GuiProject, profile: GuiProjectProfile) -> Path: + """Persist one custom project profile in the schema directory.""" + if not profile.custom: + raise ValueError("configured project profiles are read-only") + if not profile.key.replace("-", "_").isalnum(): + raise ValueError("project name may contain only letters, numbers, '-' and '_'") + path = (project.root / f"{profile.key}.toml").resolve() + if project.root not in path.parents: + raise ValueError("custom project file must remain inside the schema directory") + if path.exists() and (profile.source is None or path != profile.source.resolve()): + existing = load_project_profile_file(project, path) + if existing.key != profile.key: + raise ValueError(f"refusing to overwrite unrelated TOML file '{path.name}'") + lines: list[str] = [] + for item in profile.profiles: + lines.extend( + [ + "[[file_profiles]]", + f'name = "{_toml_escape(item.name)}"', + f'default_file = "{_toml_escape(item.default_file)}"', + "namelists = [" + + ", ".join(f'"{_toml_escape(page.name)}"' for page in item.pages) + + "]", + ] + ) + if item.title != item.name: + lines.append(f'title = "{_toml_escape(item.title)}"') + if item.description: + lines.append(f'description = "{_toml_escape(item.description)}"') + if item.required: + lines.append( + "required = [" + + ", ".join(f'"{_toml_escape(name)}"' for name in item.required) + + "]" + ) + lines.append("") + lines.extend( + [ + "[[project_profiles]]", + f'name = "{_toml_escape(profile.name)}"', + "file_profiles = [" + + ", ".join(f'"{_toml_escape(item.name)}"' for item in profile.profiles) + + "]", + "", + ] + ) + if profile.title != profile.name: + lines.insert(-1, f'title = "{_toml_escape(profile.title)}"') + if profile.description: + lines.insert(-1, f'description = "{_toml_escape(profile.description)}"') + _atomic_write(path, "\n".join(lines)) + return path + + +def _toml_escape(value: str) -> str: + """Escape a string for the small TOML subset written by the GUI.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + + +def _evaluated_group_values( + evaluated: EvaluatedGroup, + schema: Mapping[str, Any], + sizes: Mapping[str, int], +) -> dict[str, Any]: + """Convert evaluated namelist leaves into schema-shaped GUI values.""" + properties = schema.get("properties", {}) + if not isinstance(properties, Mapping): + raise ValueError(f"schema for namelist '{evaluated.name}' has invalid properties") + result: dict[str, Any] = {} + for name, prop in properties.items(): + if not isinstance(name, str) or not isinstance(prop, Mapping): + continue + states = [ + (coordinates, component, state) + for (root, coordinates, component), state in evaluated.states.items() + if root == name.lower() and state.explicitly_assigned + ] + if not states: + continue + if prop.get("type") == "array": + result[name] = _evaluated_array(prop, states, sizes) + elif prop.get("type") == "object": + components = _component_names(prop) + result[name] = { + components[component]: _imported_scalar(state.value) + for _, component, state in states + if component in components + } + else: + result[name] = _imported_scalar(states[-1][2].value) + return result + + +def _evaluated_array( + schema: Mapping[str, Any], + states: list[tuple[tuple[int, ...], str | None, LeafState]], + sizes: Mapping[str, int], +) -> list[Any]: + """Rebuild a dense editable array while retaining assigned indices.""" + items = schema.get("items") + if not isinstance(items, Mapping): + raise ValueError("array field must define object 'items'") + shape = list(resolve_shape(schema, sizes)) + raw = schema["x-fortran-shape"] + raw = raw if isinstance(raw, list) else [raw] + for axis in range(len(shape)): + if raw[axis] != ":": + continue + used = [coordinates[axis] for coordinates, _, _ in states if coordinates] + if used: + shape[axis] = max(used) + result = cast(list[Any], suggestion({**schema, "x-fortran-shape": shape}, sizes)) + components = _component_names(items) if items.get("type") == "object" else {} + for coordinates, component, state in states: + target = result + for coordinate in coordinates[:-1]: + target = target[coordinate - 1] + index = coordinates[-1] - 1 + value = _imported_scalar(state.value) + if component is None: + target[index] = value + elif component in components: + target[index][components[component]] = value + return InputArray(result, {coordinates for coordinates, _, _ in states}) + + +def _component_names(schema: Mapping[str, Any]) -> dict[str, str]: + """Map lower-case derived component names to schema spelling.""" + properties = schema.get("properties", {}) + if not isinstance(properties, Mapping): + return {} + return {name.lower(): name for name in properties if isinstance(name, str)} + + +def _filled(shape: tuple[int, ...], value: Any) -> Any: + """Build a nested array of copied values for an evaluated shape.""" + if not shape: + return copy.deepcopy(value) + return [_filled(shape[1:], value) for _ in range(shape[0])] + + +def _imported_scalar(value: Any) -> Any: + """Copy an imported scalar and trim Fortran string padding.""" + return value.rstrip() if isinstance(value, str) else copy.deepcopy(value) + + +def _normalize_profile_values( + raw: Any, + profile: GuiProfile, + sizes: Mapping[str, int], +) -> dict[str, dict[str, Any]]: + """Validate profile names and normalize fields to schema spelling.""" + if not isinstance(raw, Mapping): + raise ValueError(f"file profile '{profile.name}' values must be an object") + pages = {page.key: page for page in profile.pages} + values: dict[str, dict[str, Any]] = {} + seen_pages: set[str] = set() + for raw_name, fields in raw.items(): + if not isinstance(raw_name, str) or not isinstance(fields, Mapping): + raise ValueError(f"profile '{profile.name}' namelists must be named objects") + page = pages.get(raw_name.lower()) + if page is None: + raise ValueError(f"profile '{profile.name}' contains unknown namelist '{raw_name}'") + if page.key in seen_pages: + raise ValueError( + f"profile '{profile.name}' repeats namelist '{raw_name}' case-insensitively" + ) + seen_pages.add(page.key) + properties = page.schema.get("properties", {}) + if not isinstance(properties, Mapping): + raise ValueError(f"schema for namelist '{page.name}' has invalid properties") + canonical = { + str(name).lower(): (str(name), schema) + for name, schema in properties.items() + if isinstance(schema, Mapping) + } + normalized_fields: dict[str, Any] = {} + seen_fields: set[str] = set() + for raw_field, value in fields.items(): + if not isinstance(raw_field, str): + raise ValueError(f"namelist '{page.name}' field names must be strings") + field = canonical.get(raw_field.lower()) + if field is None: + raise ValueError(f"namelist '{page.name}' contains unknown field '{raw_field}'") + field_name, field_schema = field + field_key = field_name.lower() + if field_key in seen_fields: + raise ValueError( + f"namelist '{page.name}' repeats field '{raw_field}' case-insensitively" + ) + seen_fields.add(field_key) + normalized_fields[field_name] = _normalize_value( + value, + field_schema, + sizes, + f"{page.name}.{field_name}", + ) + values[page.name] = normalized_fields + return values + + +def _normalize_value( + value: Any, + schema: Mapping[str, Any], + sizes: Mapping[str, int], + path: str, +) -> Any: + """Validate and normalize one scalar, array, or derived value.""" + kind = schema.get("type") + if kind == "array": + if not isinstance(value, list): + raise ValueError(f"'{path}' must be an array") + items = schema.get("items") + if not isinstance(items, Mapping): + raise ValueError(f"array '{path}' must define object items") + assigned = value.assigned if isinstance(value, InputArray) else None + value = initial_array(schema, sizes, value, suggestion(items, sizes), strict=True) + + def normalize_items(node: Any, indices: tuple[int, ...] = ()) -> Any: + if isinstance(node, list): + return [ + normalize_items(item, (*indices, index)) + for index, item in enumerate(node, start=1) + ] + if assigned is not None and indices not in assigned: + return node + suffix = "".join(f"[{index}]" for index in indices) + return _normalize_value(node, items, sizes, f"{path}{suffix}") + + normalized = normalize_items(value) + return InputArray(normalized, assigned) if assigned is not None else normalized + if kind == "object": + if not isinstance(value, Mapping): + raise ValueError(f"'{path}' must be an object") + properties = schema.get("properties") + if not isinstance(properties, Mapping): + raise ValueError(f"derived value '{path}' must define properties") + canonical = { + str(name).lower(): (str(name), child) + for name, child in properties.items() + if isinstance(child, Mapping) + } + result: dict[str, Any] = {} + seen: set[str] = set() + for raw_name, child_value in value.items(): + if not isinstance(raw_name, str): + raise ValueError(f"derived value '{path}' component names must be strings") + child = canonical.get(raw_name.lower()) + if child is None: + raise ValueError(f"derived value '{path}' contains unknown component '{raw_name}'") + child_name, child_schema = child + child_key = child_name.lower() + if child_key in seen: + raise ValueError( + f"derived value '{path}' repeats component '{raw_name}' case-insensitively" + ) + seen.add(child_key) + result[child_name] = _normalize_value( + child_value, + child_schema, + sizes, + f"{path}.{child_name}", + ) + return result + constraints = _scalar_constraints(path, schema, str(kind), dict(sizes), None) + _validate_scalar_value(path, value, constraints) + return copy.deepcopy(value) + + +def _normalize_dimensions(dimensions: Mapping[str, int], project: GuiProject) -> dict[str, int]: + """Merge dimension overrides with project defaults and validate them.""" + if not isinstance(dimensions, Mapping): + raise ValueError("dimensions must map names to positive integers") + result = dict(project.default_dimensions) + for name, value in dimensions.items(): + if not isinstance(name, str): + raise ValueError("dimension names must be strings") + key = name.lower() + if key not in result: + raise ValueError(f"unknown dimension '{name}'") + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ValueError(f"dimension '{name}' must be a positive integer") + result[key] = value + return result + + +def overlay_values(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]: + """Overlay supplied fields/components without discarding sibling values.""" + result = copy.deepcopy(dict(base)) + for name, value in override.items(): + if isinstance(value, Mapping) and isinstance(result.get(name), Mapping): + result[name] = overlay_values(result[name], value) + else: + result[name] = copy.deepcopy(value) + return result + + +def _profile_path(project: GuiProject, profile: GuiProfile) -> Path: + """Resolve and confine a profile's file beneath the output directory.""" + path = (project.output_root / profile.default_file).resolve() + if path == project.output_root or project.output_root not in path.parents: + raise ValueError("profile output must be a file inside the output directory") + return path + + +def _parsed_groups(path: Path) -> tuple[str, dict[str, Any]]: + """Read a namelist file and index its unique groups by lower-case name.""" + text = path.read_text(encoding="utf-8") + parsed = parse_namelist(text, source=str(path)) + groups = {} + for group in parsed.groups: + key = group.name.lower() + if key in groups: + raise ValueError(f"namelist '{group.name}' appears multiple times in {path}") + groups[key] = group + if not groups: + raise ValueError(f"namelist file '{path}' contains no namelist groups") + return text, groups + + +def recover_dimensions( + project: GuiProject, + paths: Iterable[Path] | None = None, + overrides: Mapping[str, int] | None = None, +) -> dict[str, int]: + """Infer editable sizes from saved indices before evaluating the namelists.""" + if paths is None: + paths = (_profile_path(project, profile) for profile in project.profiles) + pages = project.namelists or tuple(page for p in project.profiles for page in p.pages) + schemas = {page.key: page.schema for page in pages} + unique_paths = tuple(dict.fromkeys(paths)) + if project.dimension_sources: + parsed = [ + parse_namelist(path.read_text(encoding="utf-8"), source=str(path)) + for path in unique_paths + if path.exists() + ] + return infer_runtime_dimensions( + parsed, + sources=project.dimension_sources, + schemas=schemas, + defaults=project.default_dimensions, + overrides=overrides, + constants=project.constants, + ) + extents: dict[str, int] = {} + explicit: dict[str, int] = {} + # Compatibility for older configs which predate explicit dimension sources. + for path in unique_paths: + if not path.exists(): + continue + _, groups = _parsed_groups(path) + saved: dict[str, int] = {} + for key, group in groups.items(): + properties = { + name.lower(): prop + for name, prop in schemas.get(key, {}).get("properties", {}).items() + } + for assignment in group.assignments: + part = assignment.designator.parts[0] + name = part.name.lower() + prop = properties.get(name, {}) + if ( + name in project.default_dimensions + and prop.get("type") == "integer" + and len(assignment.designator.parts) == 1 + and not part.selectors + ): + for value in _expand_values(assignment): + if isinstance(value, RawValue) and not value.quoted: + saved[name] = int(value.source_text.split("_")[0]) + if prop.get("type") != "array": + continue + raw = prop["x-fortran-shape"] + shape = raw if isinstance(raw, list) else [raw] + selectors = part.selectors[0].selectors if part.selectors else () + count = _value_count(assignment) + items = prop["items"] + if items.get("type") == "object" and len(assignment.designator.parts) == 1: + count = (count + len(items["properties"]) - 1) // len(items["properties"]) + for axis, token in enumerate(shape): + dimension = str(token).lower() + if dimension not in project.default_dimensions: + continue + selector = selectors[axis] if axis < len(selectors) else None + extent = 0 + if isinstance(selector, ScalarSelector): + extent = selector.value + (max(0, count - 1) if len(shape) == 1 else 0) + elif selector is not None and selector.upper is not None: + stride = selector.stride or 1 + indices = range( + selector.lower or 1, selector.upper + (1 if stride > 0 else -1), stride + ) + extent = max(indices[0], indices[-1]) if indices else 0 + elif len(shape) == 1: + lower = (selector.lower or 1) if selector is not None else 1 + stride = (selector.stride or 1) if selector is not None else 1 + extent = max(lower, lower + (count - 1) * stride) if count else 0 + if extent > 0: + extents[dimension] = max(extents.get(dimension, 0), extent) + for name, size in saved.items(): + if name in explicit and explicit[name] != size: + raise ValueError(f"conflicting saved dimension '{name}'") + explicit[name] = size + _normalize_dimensions(overrides or {}, project) + # ponytail: sparse external files give lower bounds, not declared capacities. + dimensions = _normalize_dimensions( + {**extents, **explicit, **{name.lower(): size for name, size in (overrides or {}).items()}}, + project, + ) + for name, extent in extents.items(): + if dimensions[name] < extent: + raise ValueError(f"dimension '{name}' is smaller than saved extent {extent}") + return dimensions + + +def ensure_dimension_sources( + project: GuiProject, profiles: Iterable[GuiProfile] +) -> tuple[GuiProfile, ...]: + """Ensure every configured dimension source occurs in exactly one file.""" + result = list(profiles) + pages = {page.key: page for page in project.namelists} + used = set().union(*(_schema_dimensions(page.schema) for item in result for page in item.pages)) + for dimension, source in project.dimension_sources.items(): + if dimension not in used: + continue + occurrences = [ + index + for index, profile in enumerate(result) + if any(page.key == source.namelist for page in profile.pages) + ] + if len(occurrences) > 1: + raise ValueError( + f"dimension '{dimension}' source namelist '{source.namelist}' " + "must occur in exactly one file profile" + ) + if occurrences: + continue + source_page = pages.get(source.namelist) + if source_page is None: + raise ValueError( + f"dimension '{dimension}' source namelist '{source.namelist}' is unavailable" + ) + if not result: + raise ValueError( + f"add a file profile for dimension source namelist '{source.namelist}'" + ) + result[0] = replace(result[0], pages=(*result[0].pages, source_page)) + return tuple(result) + + +def _schema_dimensions(schema: Mapping[str, Any]) -> set[str]: + """Collect symbolic array dimensions used by a resolved schema.""" + result: set[str] = set() + shape = schema.get("x-fortran-shape") + for token in shape if isinstance(shape, list) else [shape]: + if isinstance(token, str) and token != ":": + result.add(token.lower()) + properties = schema.get("properties", {}) + if isinstance(properties, Mapping): + for child in properties.values(): + if isinstance(child, Mapping): + result.update(_schema_dimensions(child)) + items = schema.get("items") + if isinstance(items, Mapping): + result.update(_schema_dimensions(items)) + return result + + +def dimension_source_values( + project: GuiProject, + profiles: Iterable[GuiProfile], + dimensions: Mapping[str, int], +) -> dict[str, dict[str, dict[str, int]]]: + """Return project dimensions expressed as their source namelist fields.""" + result: dict[str, dict[str, dict[str, int]]] = {} + profile_items = tuple(profiles) + used = set().union( + *(_schema_dimensions(page.schema) for item in profile_items for page in item.pages) + ) + for dimension, source in project.dimension_sources.items(): + if dimension not in used: + continue + matches = [ + profile + for profile in profile_items + if any(page.key == source.namelist for page in profile.pages) + ] + if len(matches) != 1: + raise ValueError( + f"dimension '{dimension}' source namelist '{source.namelist}' " + "must occur in exactly one file profile" + ) + profile = matches[0] + page = next(page for page in profile.pages if page.key == source.namelist) + result.setdefault(profile.key, {}).setdefault(page.name, {})[source.property] = dimensions[ + dimension + ] + return result + + +def load_profile( + project: GuiProject, + profile: GuiProfile, + dimensions: Mapping[str, int], + path: Path | None = None, +) -> dict[str, Any]: + """Read selected groups; absent fields are supplied by the form's defaults.""" + dimensions = _normalize_dimensions(dimensions, project) + for page in profile.pages: + validate_schema_defaults(page.schema, constants=project.constants, dimensions=dimensions) + path = _profile_path(project, profile) if path is None else path + if not path.exists(): + return {} + _, groups = _parsed_groups(path) + sizes = {**project.constants, **dimensions} + result = {} + for page in profile.pages: + group = groups.get(page.key) + if group is not None: + evaluated = evaluate_group( + group, + page.schema, + source=str(path), + constants=project.constants, + dimensions=dimensions, + ) + result[page.name] = _evaluated_group_values(evaluated, page.schema, sizes) + return result + + +def _assignments(name: str, value: Any, schema: Mapping[str, Any]) -> Iterable[str]: + """Yield indexed Fortran assignments recursively for one schema value.""" + if schema["type"] == "array": + assigned = value.assigned if isinstance(value, InputArray) else None + + def elements(node: Any, indices: tuple[int, ...] = ()) -> Iterable[str]: + if isinstance(node, list): + for index, child in enumerate(node, 1): + yield from elements(child, (*indices, index)) + else: + if assigned is not None and indices not in assigned: + return + suffix = ",".join(map(str, indices)) + yield from _assignments(f"{name}({suffix})", node, schema["items"]) + + yield from elements(value) + elif schema["type"] == "object": + for component, child in value.items(): + yield from _assignments(f"{name}%{component}", child, schema["properties"][component]) + else: + if schema["type"] == "string" and schema.get("format") == "file-path" and value == "": + return + category = "real" if schema["type"] == "number" else schema["type"] + yield f" {name} = {_format_scalar_default(value, None, category)}" + + +def render_profile( + project: GuiProject, + profile: GuiProfile, + values: Mapping[str, Any], + dimensions: Mapping[str, int], +) -> dict[str, str]: + """Render and validate individual groups, retaining explicit Fortran indices.""" + dimensions = _normalize_dimensions(dimensions, project) + normalized = _normalize_profile_values(values, profile, {**project.constants, **dimensions}) + rendered = {} + for page in profile.pages: + if page.name not in normalized: + continue + lines = [f"&{page.name}"] + for name, value in normalized[page.name].items(): + lines.extend(_assignments(name, value, page.schema["properties"][name])) + text = "\n".join([*lines, "/", ""]) + evaluate_group( + parse_namelist(text).groups[0], + page.schema, + constants=project.constants, + dimensions=dimensions, + ) + rendered[page.key] = text + return rendered + + +def save_profiles( + project: GuiProject, + updates: Iterable[tuple[GuiProfile, Mapping[str, Any], Mapping[str, int]]], +) -> None: + """Validate all updates, then replace files without removing unselected groups.""" + outputs: dict[Path, str] = {} + for profile, values, dimensions in updates: + path = _profile_path(project, profile) + if path in outputs: + raise ValueError(f"multiple open profiles write to '{path}'") + rendered = render_profile(project, profile, values, dimensions) + text, groups = _parsed_groups(path) if path.exists() else ("", {}) + for key, group in reversed(list(groups.items())): + if key in rendered: + replacement = rendered.pop(key).rstrip("\n") + text = text[: group.span.start.offset] + replacement + text[group.span.end.offset :] + for replacement in rendered.values(): + text += ("\n" if text and not text.endswith("\n") else "") + replacement + outputs[path] = text + for path, text in outputs.items(): + _atomic_write(path, text) + + +def _atomic_write(path: Path, content: str) -> None: + """Replace a namelist file atomically after writing beside the target.""" + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle: + handle.write(content) + os.replace(temporary, path) + except Exception: + temporary.unlink(missing_ok=True) + raise diff --git a/src/nml_tools/gui/ui/__init__.py b/src/nml_tools/gui/ui/__init__.py new file mode 100644 index 0000000..d302ec2 --- /dev/null +++ b/src/nml_tools/gui/ui/__init__.py @@ -0,0 +1,12 @@ +# mypy: disable-error-code="import-not-found, no-untyped-call" +"""Designer resource loading shared by GUI components.""" + +from importlib.resources import files +from typing import Any + +from qtpy import uic + + +def load_ui(name: str, widget: Any) -> Any: + """Populate *widget* from a packaged Qt Designer file.""" + return uic.loadUi(str(files(__package__).joinpath(name)), widget) diff --git a/src/nml_tools/gui/ui/app.ui b/src/nml_tools/gui/ui/app.ui new file mode 100644 index 0000000..85a3583 --- /dev/null +++ b/src/nml_tools/gui/ui/app.ui @@ -0,0 +1,138 @@ + + + ConfigurationDialog + + + + 0 + 0 + 1100 + 720 + + + + Namelist configuration + + + + + + + + + 120 + 16777215 + + + + Project + + + + + + + + + + Browse… + + + + + + + + + + + + + + + + + + 0 + 0 + + + + Qt::Horizontal + + + + + 1 + 0 + + + + + 260 + 0 + + + + + Project structure + + + + + + + + + + + + 3 + 0 + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Restore all + + + + + + + Save all + + + + + + + Close + + + + + + + + + + diff --git a/src/nml_tools/gui/ui/array_table.ui b/src/nml_tools/gui/ui/array_table.ui new file mode 100644 index 0000000..ace772f --- /dev/null +++ b/src/nml_tools/gui/ui/array_table.ui @@ -0,0 +1,13 @@ + + + ArrayTable + + + 0000 + QFrame::HLineQFrame::Sunken + Array propertyQt::Horizontal4020i + + + + + diff --git a/src/nml_tools/gui/ui/field_row.ui b/src/nml_tools/gui/ui/field_row.ui new file mode 100644 index 0000000..8dcaab6 --- /dev/null +++ b/src/nml_tools/gui/ui/field_row.ui @@ -0,0 +1,13 @@ + + + FieldRow + + + 0000 + Property + 0000 + Show property descriptionProperty informationicolor: #267ac3; font-weight: bold; + + + + diff --git a/src/nml_tools/gui/ui/profile_config.ui b/src/nml_tools/gui/ui/profile_config.ui new file mode 100644 index 0000000..5fdade3 --- /dev/null +++ b/src/nml_tools/gui/ui/profile_config.ui @@ -0,0 +1,37 @@ + + + ProfileConfigTab + + + + Load file profile + + Browse… + + Runtime dimensions + File profile + + + + Qt::Vertical2040 + > + >> + < + << + Qt::Vertical2040 + + + + + Profile name + + Default file name + + + + Qt::Vertical2040 + Qt::Horizontal4020Run + + + + diff --git a/src/nml_tools/gui/ui/profile_tab.ui b/src/nml_tools/gui/ui/profile_tab.ui new file mode 100644 index 0000000..4d71646 --- /dev/null +++ b/src/nml_tools/gui/ui/profile_tab.ui @@ -0,0 +1,100 @@ + + + ProfileTab + + + + 0 + 0 + 536 + 108 + + + + + + + File profile description + + + true + + + + + + + 0 + + + + + Selected Namelist: + + + + + + + + + + + + + + + + + Back + + + + + + + Next + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Restore defaults + + + + + + + Cancel + + + + + + + Save + + + + + + + + + + diff --git a/tests/test_cli_gui.py b/tests/test_cli_gui.py new file mode 100644 index 0000000..4efdced --- /dev/null +++ b/tests/test_cli_gui.py @@ -0,0 +1,36 @@ +"""GUI CLI wiring without starting a Qt event loop.""" + +import subprocess +import sys + +from click.testing import CliRunner + +import nml_tools.gui +from nml_tools.cli import cli + + +def test_gui_command_paths_and_help(tmp_path, monkeypatch): + calls = [] + monkeypatch.setattr(nml_tools.gui, "launch_gui", lambda *args: calls.append(args) or 0) + result = CliRunner().invoke(cli, ["gui", "-i", str(tmp_path), "-o", str(tmp_path / "out")]) + assert result.exit_code == 0 + assert calls == [(tmp_path, tmp_path / "out")] + help_text = CliRunner().invoke(cli, ["gui", "--help"]).output + assert "--input-path" in help_text and "--output-path" in help_text + assert "--fetch-values" not in help_text + + +def test_gui_import_is_lazy(): + subprocess.run( + [ + sys.executable, + "-c", + ( + "import sys, nml_tools.gui; " + "assert 'qtpy' not in sys.modules; " + "assert 'guidata' not in sys.modules; " + "assert 'numpy' not in sys.modules" + ), + ], + check=True, + ) diff --git a/tests/test_cli_metadata.py b/tests/test_cli_metadata.py new file mode 100644 index 0000000..8f621aa --- /dev/null +++ b/tests/test_cli_metadata.py @@ -0,0 +1,393 @@ +"""Configuration metadata and source-file dimension inference integration.""" + +from __future__ import annotations + +import copy +import importlib +from pathlib import Path +from textwrap import dedent +from typing import Any + +import click +import pytest +from click.testing import CliRunner + +from nml_tools._dimensions import DimensionSource +from nml_tools.schema import SchemaResolver + +cli: Any = importlib.import_module("nml_tools.cli") + + +@pytest.fixture +def project(tmp_path: Path) -> Path: + (tmp_path / "settings.yml").write_text( + dedent(""" + $defs: + size: + type: integer + default: 7 + minimum: 1 + type: object + x-fortran-namelist: Settings + properties: + Count: + $ref: '#/$defs/size' + weights: + type: array + items: {type: integer} + x-fortran-shape: [n_values] + required_other: {type: integer} + required: [required_other] + """), + encoding="ascii", + ) + (tmp_path / "data.yml").write_text( + dedent(""" + type: object + x-fortran-namelist: data + properties: + values: + type: array + items: {type: integer} + x-fortran-shape: [n_values] + required: [values] + """), + encoding="ascii", + ) + config = tmp_path / "nml-config.toml" + config.write_text( + dedent(""" + [helper] + path = "out/helper.f90" + [kinds] + module = "iso_fortran_env" + [dimensions.n_values] + default = 1 + source = { namelist = "SETTINGS", property = "COUNT" } + [[namelists]] + schema = "data.yml" + mod_path = "out/data.f90" + doc_path = "out/data.md" + [[namelists]] + schema = "settings.yml" + mod_path = "out/settings.f90" + doc_path = "out/settings.md" + [[file_profiles]] + name = "Main" + default_file = "data.nml" + namelists = ["data"] + required = ["data"] + [[file_profiles]] + name = "Dimensions" + default_file = "settings.nml" + namelists = ["settings"] + [[project_profiles]] + name = "Standard" + title = "Standard run" + description = "Both configuration files." + file_profiles = ["MAIN", "dimensions"] + [[project_profiles]] + name = "Minimal" + file_profiles = ["main"] + [[templates]] + path = "out/data.nml" + profile = "main" + """), + encoding="ascii", + ) + (tmp_path / "data.nml").write_text("&data values=1,2,3 /", encoding="ascii") + (tmp_path / "settings.nml").write_text("&settings count=3 /", encoding="ascii") + return config + + +def load_metadata(config_path: Path) -> tuple[dict[str, Any], dict[str, Any]]: + config, _ = cli._load_config_checked(config_path) + registry = cli._namelist_registry_by_key( + cli._load_namelist_registry(config, config_path.parent, SchemaResolver()) + ) + return config, registry + + +def test_metadata_is_retained_and_resolved(project: Path) -> None: + config, registry = load_metadata(project) + metadata = cli._load_config_metadata(config, registry) + assert metadata.dimension_sources == {"n_values": DimensionSource("settings", "Count")} + profile = metadata.project_profiles["standard"] + assert profile.name == "Standard" + assert profile.key == "standard" + assert profile.title == "Standard run" + assert profile.description == "Both configuration files." + assert profile.file_profiles == ["main", "dimensions"] + assert metadata.project_profiles["minimal"].file_profiles == ["main"] + config["dimensions"]["Count"] = config["dimensions"].pop("n_values") + del config["dimensions"]["Count"]["source"]["property"] + assert cli._load_dimension_sources(config, registry) == { + "count": DimensionSource("settings", "Count") + } + + +def test_empty_metadata_and_overlapping_sources_are_allowed(project: Path) -> None: + config, registry = load_metadata(project) + file_profiles = cli._iter_file_profiles(config, registry) + assert cli._iter_project_profiles({}, file_profiles) == {} + assert cli._iter_project_profiles({"project_profiles": []}, file_profiles) == {} + config["file_profiles"][0]["namelists"].append("settings") + config["file_profiles"][0]["required"] = [] + assert cli._load_config_metadata(config, registry).project_profiles["standard"].file_profiles + assert cli._load_dimension_sources({}, registry) == {} + + +@pytest.mark.parametrize( + "source", + [ + None, + "settings", + [], + {}, + {"namelist": 3}, + {"namelist": ""}, + {"namelist": "unknown"}, + {"namelist": "settings", "property": "unknown"}, + {"namelist": "settings", "property": None}, + {"namelist": "settings", "property": ""}, + {"namelist": "settings", "property": "count(1)"}, + {"namelist": "settings", "property": "count%value"}, + {"namelist": "settings", "property": "weights"}, + ], +) +def test_invalid_source_metadata(project: Path, source: Any) -> None: + config, registry = load_metadata(project) + config["dimensions"]["n_values"]["source"] = source + with pytest.raises(click.ClickException, match="dimension 'n_values' source"): + cli._load_config_metadata(config, registry) + + +@pytest.mark.parametrize( + "prop", + [ + {"type": "number"}, + {"type": "boolean"}, + {"type": "string", "x-fortran-len": 3}, + {"type": "object", "properties": {"value": {"type": "integer"}}}, + ], +) +def test_source_requires_integer_scalar(project: Path, prop: dict[str, Any]) -> None: + config, registry = load_metadata(project) + registry["settings"].schema["properties"]["Count"] = prop + with pytest.raises(click.ClickException, match="intrinsic integer scalar"): + cli._load_config_metadata(config, registry) + + +@pytest.mark.parametrize( + "entries", + [ + None, + {}, + "standard", + [None], + [{}], + [{"name": ""}], + [{"name": 3}], + [{"name": "standard", "file_profiles": []}], + [{"name": "standard", "file_profiles": "main"}], + [{"name": "standard", "file_profiles": [None]}], + [{"name": "standard", "file_profiles": [""]}], + [{"name": "standard", "file_profiles": ["unknown"]}], + [{"name": "standard", "file_profiles": ["main", "MAIN"]}], + [{"name": "standard", "file_profiles": ["main"], "title": 3}], + [{"name": "standard", "file_profiles": ["main"], "description": []}], + [ + {"name": "standard", "file_profiles": ["main"]}, + {"name": "STANDARD", "file_profiles": ["dimensions"]}, + ], + ], +) +def test_invalid_project_metadata(project: Path, entries: Any) -> None: + config, registry = load_metadata(project) + config["project_profiles"] = entries + with pytest.raises(click.ClickException): + cli._load_config_metadata(config, registry) + + +def run_validate(project: Path, *options: str) -> Any: + return CliRunner().invoke( + cli.cli, ["validate", "--config", str(project), *options, str(project.parent / "data.nml")] + ) + + +def test_dim_file_inference_with_profile_and_explicit_schema(project: Path) -> None: + dim_file = str(project.parent / "settings.nml") + for options in [("--profile", "MAIN"), ("--schema", str(project.parent / "data.yml")), ()]: + result = run_validate(project, *options, "--dim-file", dim_file) + assert result.exit_code == 0, result.output + # Without the source file, the configured default of one is too small. + assert run_validate(project).exit_code != 0 + # All target requirements still apply; context does not satisfy them. + (project.parent / "data.nml").write_text("&data /", encoding="ascii") + result = run_validate(project, "--dim-file", dim_file) + assert result.exit_code != 0 + assert "missing required 'values'" in result.output + + +def test_same_file_sources_are_inferred_before_all_groups(project: Path) -> None: + (project.parent / "data.nml").write_text( + "&data values=1,2,3 / &settings weights=1,2,3 count=3 required_other=2 /", + encoding="ascii", + ) + result = run_validate(project) + assert result.exit_code == 0, result.output + + +def test_dim_file_is_context_only_and_overrides_win(project: Path) -> None: + dim_file = project.parent / "settings.nml" + dim_file.write_text( + "&unrelated ignored=1 / &settings count=2 weights=999*1 /", encoding="ascii" + ) + result = run_validate(project, "--dim-file", str(dim_file), "--dimensions", "N_VALUES=3") + assert result.exit_code == 0, result.output + dim_file.write_text("&settings count=, /", encoding="ascii") + (project.parent / "data.nml").write_text("&data values=1 /", encoding="ascii") + result = run_validate(project, "--dim-file", str(dim_file)) + assert result.exit_code == 0, result.output + + +def test_dim_file_cannot_supply_required_target_group(project: Path) -> None: + (project.parent / "data.nml").write_text("", encoding="ascii") + result = run_validate( + project, "--profile", "main", "--dim-file", str(project.parent / "settings.nml") + ) + assert result.exit_code != 0 + assert "input is missing namelist 'data'" in result.output + + +def test_runtime_ambiguity_and_source_locations(project: Path) -> None: + (project.parent / "data.nml").write_text( + "&data values=1,2,3 / &settings count=3 required_other=2 /", encoding="ascii" + ) + result = run_validate(project, "--dim-file", str(project.parent / "settings.nml")) + assert result.exit_code != 0 + assert "ambiguous source namelist" in result.output + assert "settings.nml:1:" in result.output + # A source file cannot silently fall back when its explicit value is invalid. + (project.parent / "data.nml").write_text("&data values=1,2,3 /", encoding="ascii") + (project.parent / "settings.nml").write_text("&settings count=0 /", encoding="ascii") + result = run_validate(project, "--dim-file", str(project.parent / "settings.nml")) + assert result.exit_code != 0 + assert "settings.nml:1:" in result.output + + +def test_identical_paths_are_read_once(project: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (project.parent / "data.nml").write_text( + "&data values=1,2,3 / &settings count=3 required_other=2 /", encoding="ascii" + ) + original = cli._read_namelist + reads: list[Path] = [] + + def read(path: Path) -> Any: + reads.append(path) + return original(path) + + monkeypatch.setattr(cli, "_read_namelist", read) + result = run_validate(project, "--dim-file", str(project.parent / "." / "data.nml")) + assert result.exit_code == 0, result.output + assert len(reads) == 1 + + +def test_dim_file_requires_config_and_reports_parse_errors(project: Path) -> None: + result = CliRunner().invoke( + cli.cli, + [ + "validate", + "--schema", + str(project.parent / "data.yml"), + "--dim-file", + str(project.parent / "settings.nml"), + str(project.parent / "data.nml"), + ], + ) + assert result.exit_code != 0 + assert "--dim-file requires config" in result.output + (project.parent / "settings.nml").write_text("&settings count=3", encoding="ascii") + result = run_validate(project, "--dim-file", str(project.parent / "settings.nml")) + assert result.exit_code != 0 + assert "failed to parse namelist" in result.output + assert "settings.nml" in result.output + + +@pytest.mark.parametrize( + "command", ["generate", "check", "gen-fortran", "gen-markdown", "gen-template", "validate"] +) +@pytest.mark.parametrize("bad_metadata", ["source", "project"]) +def test_commands_reject_metadata_before_writing( + project: Path, + command: str, + bad_metadata: str, +) -> None: + text = project.read_text(encoding="ascii") + if bad_metadata == "source": + text = text.replace('property = "COUNT"', 'property = "unknown"') + else: + text = text.replace('file_profiles = ["MAIN", "dimensions"]', 'file_profiles = ["unknown"]') + project.write_text(text, encoding="ascii") + args = [command, "--config", str(project)] + if command == "validate": + args.append(str(project.parent / "data.nml")) + result = CliRunner().invoke(cli.cli, args) + assert result.exit_code != 0 + assert "unknown" in result.output + assert not (project.parent / "out").exists() + + +def test_metadata_does_not_change_generated_outputs(project: Path) -> None: + config, _ = load_metadata(project) + original = copy.deepcopy(config) + original.pop("project_profiles") + original["dimensions"]["n_values"].pop("source") + assert cli._collect_generated_outputs(config, project) == cli._collect_generated_outputs( + original, project + ) + + +def test_metadata_works_in_pyproject_tool_section(project: Path) -> None: + text = project.read_text(encoding="ascii") + # Prefix every table/array header while leaving inline tables intact. + text = "\n".join( + line.replace("[[", "[[tool.nml-tools.", 1) + if line.startswith("[[") + else line.replace("[", "[tool.nml-tools.", 1) + if line.startswith("[") + else line + for line in text.splitlines() + ) + pyproject = project.parent / "pyproject.toml" + pyproject.write_text(text, encoding="ascii") + config, registry = load_metadata(pyproject) + assert cli._load_config_metadata(config, registry).project_profiles["standard"] + result = run_validate(pyproject, "--dim-file", str(project.parent / "settings.nml")) + assert result.exit_code == 0, result.output + + +@pytest.mark.parametrize( + "options", + [ + ["check", "--diff"], + ["validate", "combined.nml"], + ["validate", "--profile", "data", "--dim-file", "settings.nml", "data.nml"], + [ + "validate", + "--profile", + "data", + "--dim-file", + "settings.nml", + "--dimensions", + "n_values=4", + "data-override.nml", + ], + ["validate", "--profile", "data", "out/data-template.nml"], + ], +) +def test_dimension_sources_example(options: list[str], monkeypatch: pytest.MonkeyPatch) -> None: + example = Path(__file__).resolve().parents[1] / "examples" / "06_dimension_sources" + monkeypatch.chdir(example) + result = CliRunner().invoke(cli.cli, [options[0], "--config", "nml-config.toml", *options[1:]]) + assert result.exit_code == 0, result.output diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py new file mode 100644 index 0000000..e2792f5 --- /dev/null +++ b/tests/test_dimensions.py @@ -0,0 +1,190 @@ +"""Dimension inference operates on source scalars before full evaluation.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from nml_tools._dimensions import ( + DimensionSource, + infer_runtime_dimensions, + resolve_dimension_source, +) +from nml_tools._namelist_eval import evaluate_group +from nml_tools._namelist_parser import parse_namelist +from nml_tools.schema import resolve_schema + + +def source_schema(**property_updates: Any) -> dict[str, Any]: + return resolve_schema( + { + "type": "object", + "x-fortran-namelist": "Settings", + "properties": { + "Count": {"type": "integer", "default": 7, **property_updates}, + "values": { + "type": "array", + "items": {"type": "integer"}, + "x-fortran-shape": ["n_values"], + }, + "required_other": {"type": "integer"}, + }, + "required": ["Count", "required_other"], + } + ) + + +def infer(text: str, **kwargs: Any) -> dict[str, int]: + return infer_runtime_dimensions( + [parse_namelist(text, source="settings.nml")], + sources={"n_values": DimensionSource("settings", "count")}, + schemas={"settings": source_schema()}, + defaults={"n_values": 1}, + **kwargs, + ) + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("&Settings Count=3 /", 3), + ("&SETTINGS COUNT=1*+3 /", 3), + ("&settings count=2 count=3 /", 3), + ("&settings count=3 count=, /", 3), + ("&settings count=, /", 1), + ("&settings count=1* /", 1), + ("&settings required_other=2 /", 1), + ("&other count=3 /", 1), + ("", 1), + ], +) +def test_inference_assignment_semantics_and_fallback(text: str, expected: int) -> None: + assert infer(text) == {"n_values": expected} + + +@pytest.mark.parametrize( + "assignment", + [ + "count=0", + "count=-1", + "count='3'", + "count=3.0", + "count=.true.", + "count(1)=3", + "count=2*3", + "count%value=3", + ], +) +def test_invalid_sources_report_locations(assignment: str) -> None: + with pytest.raises(ValueError, match=r"settings.nml:1:\d+:"): + infer(f"&settings {assignment} /") + + +@pytest.mark.parametrize( + ("body", "value_line"), + [ + ("count=-1\ncount=,", 2), + ("count=0\ncount=1*", 2), + ("count=-2\ncount=-1\ncount=,", 3), + ], +) +def test_retained_invalid_source_reports_last_explicit_value(body: str, value_line: int) -> None: + with pytest.raises(ValueError, match=rf"settings\.nml:{value_line}:7:.*must be positive"): + infer(f"&settings\n{body}\n/") + + +def test_overrides_skip_source_extraction_and_ambiguity() -> None: + assert infer("&settings count=0 / &settings count='bad' /", overrides={"N_VALUES": 4}) == { + "n_values": 4 + } + + +def test_source_constraints_use_existing_evaluator() -> None: + with pytest.raises(ValueError, match=r"settings.nml:1:.*must be <= 3"): + infer_runtime_dimensions( + [parse_namelist("&settings count=4 /", source="settings.nml")], + sources={"n_values": DimensionSource("settings", "count")}, + schemas={"settings": source_schema(maximum=3, default=2)}, + defaults={"n_values": 1}, + ) + + +def test_two_pass_evaluation_avoids_default_shape_and_missing_other_fields() -> None: + schema = source_schema() + parsed = parse_namelist("&settings values=1,2,3 count=3 required_other=2 /") + dimensions = infer_runtime_dimensions( + [parsed], + sources={"n_values": DimensionSource("Settings", "Count")}, + schemas={"settings": schema}, + defaults={"n_values": 1}, + ) + assert dimensions == {"n_values": 3} + evaluate_group(parsed.groups[0], schema, dimensions=dimensions) + + +def test_inference_accepts_multiple_files_and_dimensions() -> None: + schemas = { + "settings": source_schema(), + "other": resolve_schema( + { + "type": "object", + "x-fortran-namelist": "other", + "properties": {"size": {"type": "integer"}}, + } + ), + } + assert infer_runtime_dimensions( + [parse_namelist("&settings count=3 /"), parse_namelist("&other size=4 /")], + sources={ + "n_values": DimensionSource("settings", "count"), + "n_other": DimensionSource("other", "size"), + }, + schemas=schemas, + defaults={"n_values": 1, "n_other": 2, "unsourced": 5}, + overrides={"additional": 6}, + ) == {"n_values": 3, "n_other": 4, "unsourced": 5, "additional": 6} + + +@pytest.mark.parametrize("split_files", [False, True]) +def test_ambiguous_groups_fail_even_with_equal_values(split_files: bool) -> None: + texts = ["&settings count=3 /", "&SETTINGS count=3 /"] + if not split_files: + texts = [" ".join(texts)] + with pytest.raises(ValueError, match="ambiguous"): + infer_runtime_dimensions( + [parse_namelist(text, source=f"input{i}.nml") for i, text in enumerate(texts)], + sources={"n_values": DimensionSource("settings", "count")}, + schemas={"settings": source_schema()}, + defaults={"n_values": 1}, + ) + + +def test_source_resolution_handles_references_and_case() -> None: + schema = resolve_schema( + { + "$defs": {"count": {"type": "integer"}}, + "type": "object", + "x-fortran-namelist": "settings", + "properties": {"Count": {"$ref": "#/$defs/count"}}, + } + ) + assert resolve_dimension_source( + DimensionSource("SETTINGS", "COUNT"), {"settings": schema} + ) == DimensionSource("settings", "Count") + + +def test_raw_references_fail_before_inference() -> None: + with pytest.raises(ValueError, match="unresolved.*ref"): + resolve_dimension_source( + DimensionSource("settings", "count"), + {"settings": {"properties": {"count": {"$ref": "#/$defs/count"}}}}, + ) + + +@pytest.mark.parametrize( + "overrides", [{"n_values": True}, {"n_values": 0}, {"n_values": 2, "N_VALUES": 3}] +) +def test_invalid_caller_overrides_fail(overrides: dict[str, Any]) -> None: + with pytest.raises(ValueError): + infer("", overrides=overrides) diff --git a/tests/test_gui_arrays.py b/tests/test_gui_arrays.py new file mode 100644 index 0000000..0c35adb --- /dev/null +++ b/tests/test_gui_arrays.py @@ -0,0 +1,145 @@ +"""Tests for Qt-independent GUI array metadata and data transforms.""" + +from __future__ import annotations + +import pytest + +from nml_tools.gui.arrays import ( + axis_labels, + canonical_array, + display_array, + initial_array, + resolve_shape, + table_axes, +) + + +def test_resolve_shape_supports_literals_names_and_saved_flexible_axes() -> None: + assert resolve_shape({"x-fortran-shape": [5, "N_DOMAINS"]}, {"n_domains": 3}) == (5, 3) + assert resolve_shape({"x-fortran-shape": ":"}, {}, [1, 2, 3]) == (3,) + + with pytest.raises(ValueError, match="unknown array dimension"): + resolve_shape({"x-fortran-shape": "missing"}, {}) + + +def test_axis_labels_support_explicit_values_and_index_templates() -> None: + schema = { + "x-nml-tools-ui": { + "axes": { + "1": {"labels": ["Lower", "Upper", "Value"]}, + "2": {"label-template": "Domain {index}"}, + } + } + } + + assert axis_labels(schema, 1, 3) == ["Lower", "Upper", "Value"] + assert axis_labels(schema, 2, 2) == ["Domain 1", "Domain 2"] + assert axis_labels(schema, 3, 4) is None + + with pytest.raises(ValueError, match="2 labels for extent 3"): + axis_labels( + {"x-nml-tools-ui": {"axes": {"1": {"labels": ["a", "b"]}}}}, + 1, + 3, + ) + + +def test_table_axes_uses_one_based_schema_metadata() -> None: + schema = { + "x-nml-tools-ui": { + "table": {"row-axis": 2, "column-axis": 1}, + } + } + + assert table_axes(schema, 2) == (1, 0) + assert table_axes({}, 2) == (0, 1) + assert table_axes({}, 1) is None + + with pytest.raises(ValueError, match="distinct valid one-based axes"): + table_axes( + {"x-nml-tools-ui": {"table": {"row-axis": 1, "column-axis": 1}}}, + 2, + ) + + +def test_parameter_array_display_round_trip_preserves_canonical_axis_order() -> None: + pytest.importorskip("numpy") + schema = { + "x-fortran-shape": [5, "n_units"], + "x-nml-tools-ui": { + "table": {"row-axis": 2, "column-axis": 1}, + }, + } + canonical = [ + [10.0, 11.0], + [20.0, 21.0], + [30.0, 31.0], + [0, 1], + [1, 0], + ] + + displayed = display_array(canonical, schema) + + assert displayed.tolist() == [ + [10.0, 20.0, 30.0, 0.0, 1.0], + [11.0, 21.0, 31.0, 1.0, 0.0], + ] + assert canonical_array(displayed, schema, rank=2) == canonical + + +def test_initial_array_broadcasts_parameter_vector_across_second_axis() -> None: + schema = {"x-fortran-shape": [5, "n_units"]} + + initialized = initial_array( + schema, + {"n_units": 2}, + [75.0, 200.0, 85.0, 1, 1], + 0.0, + ) + + assert initialized == [ + [75.0, 75.0], + [200.0, 200.0], + [85.0, 85.0], + [1, 1], + [1, 1], + ] + + +def test_derived_array_initialization_does_not_share_mutable_defaults() -> None: + initialized = initial_array( + {"x-fortran-shape": 2}, + {}, + None, + {"enabled": False}, + ) + + initialized[0]["enabled"] = True + assert initialized == [{"enabled": True}, {"enabled": False}] + + +def test_saved_arrays_are_completed_without_repeating_or_moving_values() -> None: + flexible = { + "x-fortran-shape": "max_items", + "x-fortran-flex-tail-dims": 1, + } + assert initial_array( + flexible, + {"max_items": 5}, + [10, 20], + 0, + strict=True, + ) == [10, 20, 0, 0, 0] + assert initial_array({"x-fortran-shape": [2, 3]}, {}, [[1, 2], [3, 4]], 9, resize=True) == [ + [1, 2, 9], + [3, 4, 9], + ] + + with pytest.raises(ValueError, match="does not match declared shape"): + initial_array( + {"x-fortran-shape": 5}, + {}, + [10, 20], + 0, + strict=True, + ) diff --git a/tests/test_gui_model.py b/tests/test_gui_model.py new file mode 100644 index 0000000..7687473 --- /dev/null +++ b/tests/test_gui_model.py @@ -0,0 +1,333 @@ +"""Direct namelist persistence and profile selection checks.""" + +from pathlib import Path +from textwrap import dedent + +import pytest + +from nml_tools.gui.model import ( + GUI_REF_ORIGIN_KEY, + create_virtual_project, + load_profile, + load_project, + load_project_profile_file, + overlay_values, + recover_dimensions, + save_profiles, + suggestion, +) + + +def _write_project(root: Path, *, duplicate_output: bool = False) -> None: + schemas = root / "nml-schemas" + schemas.mkdir() + (schemas / "alpha.yml").write_text( + dedent( + """ + title: Alpha settings + x-fortran-namelist: alpha + type: object + properties: + count: + type: integer + label: + type: string + x-fortran-len: 32 + weights: + type: array + x-fortran-shape: n_items + items: + type: number + options: + type: object + x-fortran-type: options_t + properties: + enabled: + type: boolean + label: + type: string + x-fortran-len: 16 + settings: + type: array + x-fortran-shape: n_items + items: + type: object + x-fortran-type: setting_t + properties: + enabled: + type: boolean + name: + type: string + x-fortran-len: 16 + required: [count] + """ + ).lstrip(), + encoding="utf-8", + ) + + (schemas / "beta.yml").write_text( + dedent( + """ + title: Beta settings + x-fortran-namelist: beta + type: object + properties: + enabled: + type: boolean + """ + ).lstrip(), + encoding="utf-8", + ) + second_output = "main.nml" if duplicate_output else "secondary.nml" + (root / "nml-config.toml").write_text( + dedent( + f""" + [dimensions] + n_items = {{ default = 2 }} + + [[namelists]] + name = "alpha" + schema = "nml-schemas/alpha.yml" + + [[namelists]] + name = "beta" + schema = "nml-schemas/beta.yml" + + [[file_profiles]] + name = "secondary" + title = "Second profile" + default_file = "{second_output}" + namelists = ["beta"] + + [[file_profiles]] + name = "main" + default_file = "main.nml" + namelists = ["beta", "alpha"] + required = ["alpha"] + """ + ).lstrip(), + encoding="utf-8", + ) + + +def test_profiles_filter_in_toml_order_and_validate_names(tmp_path): + _write_project(tmp_path) + project = load_project(tmp_path, tmp_path / "output", {"MAIN": ["alpha"], "secondary": []}) + assert [p.name for p in project.profiles] == ["secondary", "main"] + assert [p.name for p in project.profile("main").pages] == ["alpha"] + assert [p.name for p in project.profile("secondary").pages] == ["beta"] + assert len(load_project(tmp_path, file_profiles={}).profiles) == 2 + for selection in ({"missing": []}, {"main": ["missing"]}, {"main": "alpha"}): + with pytest.raises(ValueError): + load_project(tmp_path, file_profiles=selection) + + +def test_load_project_marks_only_referenced_arrays(tmp_path): + _write_project(tmp_path) + schemas = tmp_path / "nml-schemas" + (schemas / "parameter.yml").write_text( + "$defs:\n parameter:\n type: array\n x-fortran-shape: 2\n" + " items:\n type: number\n", + encoding="utf-8", + ) + alpha = schemas / "alpha.yml" + alpha.write_text( + alpha.read_text(encoding="utf-8").replace( + " weights:\n type: array\n x-fortran-shape: n_items\n" + " items:\n type: number\n", + " weights:\n $ref: parameter.yml#/$defs/parameter\n", + ), + encoding="utf-8", + ) + properties = load_project(tmp_path).profile("main").pages[1].schema["properties"] + assert properties["weights"][GUI_REF_ORIGIN_KEY] == "parameter.yml#/$defs/parameter" + assert GUI_REF_ORIGIN_KEY not in properties["settings"] + + +def test_save_reload_preserves_unselected_groups_and_uses_no_json(tmp_path): + _write_project(tmp_path) + project = load_project(tmp_path, file_profiles={"main": ["alpha"]}) + path = tmp_path / "main.nml" + untouched = "! keep this\n&beta enabled=.true. / ! keep too\n" + path.write_text(untouched + "&ALPHA count=1 /\n") + profile = project.profile("main") + values = { + "alpha": { + "count": 4, + "label": 'a "quote" / !', + "weights": [1.5, 2.5], + "options": {"enabled": True, "label": "single"}, + "settings": [{"enabled": True, "name": "first"}, {"enabled": False, "name": "second"}], + } + } + save_profiles(project, [(profile, values, {"n_items": 2})]) + saved = path.read_text() + assert saved.startswith(untouched) + assert "weights(2) = 2.5" in saved + assert "settings(1)%enabled = .true." in saved + assert load_profile(project, profile, {"n_items": 2}) == values + assert not list(tmp_path.glob("*.json")) + + +def test_all_saves_validate_before_replacing_any_file(tmp_path): + _write_project(tmp_path) + project = load_project(tmp_path) + path = tmp_path / "secondary.nml" + original = "&beta enabled=.true. /\n" + path.write_text(original) + with pytest.raises(ValueError): + save_profiles( + project, + [ + (project.profile("secondary"), {"beta": {"enabled": False}}, {}), + (project.profile("main"), {"alpha": {"count": "invalid"}}, {}), + ], + ) + assert path.read_text() == original + assert not (tmp_path / "main.nml").exists() + + +def test_missing_input_errors_and_partial_array_input(tmp_path): + _write_project(tmp_path) + project = load_project(tmp_path) + profile = project.profile("main") + assert load_profile(project, profile, {}) == {} + path = tmp_path / "main.nml" + path.write_text("&alpha count=2 weights(2)=3.5 options%enabled=.true. /\n") + values = load_profile(project, profile, {}) + assert values["alpha"]["weights"] == [0.0, 3.5] + assert values["alpha"]["options"] == {"enabled": True} + for text in ( + "&alpha count=2 / &alpha count=3 /", + "&alpha count=2 weights(3)=1 /", + "&alpha count=2", + ): + path.write_text(text) + with pytest.raises(ValueError): + load_profile(project, profile, {}) + + +def test_complete_arrays_and_recover_dimensions_before_loading(tmp_path): + _write_project(tmp_path) + project = load_project(tmp_path) + profile = project.profile("main") + schema = next(page.schema for page in project.namelists if page.key == "alpha") + weights = schema["properties"]["weights"] + weights.update({"x-fortran-shape": [2, "n_items"], "x-fortran-flex-tail-dims": 1}) + path = tmp_path / "main.nml" + for size in (3, 1): + values = {"alpha": {"count": 1, "weights": [[4.0], [5.0]]}} + save_profiles(project, [(profile, values, {"n_items": size})]) + assert f"weights(2,{size}) =" in path.read_text() + dimensions = recover_dimensions(project) + assert dimensions == {"n_items": size} + assert load_profile(project, profile, dimensions)["alpha"]["weights"] == [ + [4.0] + [0.0] * (size - 1), + [5.0] + [0.0] * (size - 1), + ] + weights["x-fortran-shape"] = "n_items" + path.write_text("&alpha count=1 weights(2:6:2)=3*1.0 /\n") + assert recover_dimensions(project) == {"n_items": 6} + assert recover_dimensions(project, overrides={"N_ITEMS": 7}) == {"n_items": 7} + with pytest.raises(ValueError, match="smaller than saved extent"): + recover_dimensions(project, overrides={"n_items": 1}) + path.write_text("&alpha count=1 weights(2:)=3*1.0 /\n") + assert recover_dimensions(project) == {"n_items": 4} + schema["properties"]["n_items"] = {"type": "integer"} + path.write_text("&alpha count=1 n_items=7 weights(1)=4.0 /\n") + assert recover_dimensions(project) == {"n_items": 7} + + +def test_array_defaults_use_fortran_order_and_declared_padding(): + schema = { + "type": "array", + "items": {"type": "integer"}, + "x-fortran-shape": [2, 2], + "default": [1, 2, 3, 4], + "examples": [[9]], + } + assert suggestion(schema, {}) == [[1, 3], [2, 4]] + assert suggestion({**schema, "x-fortran-default-order": "C"}, {}) == [[1, 2], [3, 4]] + assert suggestion({**schema, "default": [1], "x-fortran-default-pad": 7}, {}) == [ + [1, 7], + [7, 7], + ] + assert suggestion({"type": "integer", "default": 4, "examples": [9]}, {}) == 4 + + +def test_overlay_keeps_unspecified_derived_components(): + original = {"alpha": {"options": {"enabled": False, "label": "keep"}}} + merged = overlay_values(original, {"alpha": {"options": {"enabled": True}}}) + assert merged["alpha"]["options"] == {"enabled": True, "label": "keep"} + assert original["alpha"]["options"]["enabled"] is False + + +def test_multidimensional_and_deferred_arrays_round_trip(tmp_path): + _write_project(tmp_path) + project = load_project(tmp_path) + profile = project.profile("main") + alpha = next(page.schema for page in profile.pages if page.name == "alpha") + alpha["properties"]["weights"]["x-fortran-shape"] = [2, 2] + alpha["properties"]["settings"]["x-fortran-shape"] = [2, 2] + values = { + "alpha": { + "count": 1, + "weights": [[1.0, 2.0], [3.0, 4.0]], + "settings": [ + [{"enabled": True, "name": "a"}, {"enabled": False, "name": "b"}], + [{"enabled": False, "name": "c"}, {"enabled": True, "name": "d"}], + ], + } + } + save_profiles(project, [(profile, values, {})]) + assert "weights(2,1) = 3.0" in (tmp_path / "main.nml").read_text() + assert 'settings(1,2)%name = "b"' in (tmp_path / "main.nml").read_text() + assert load_profile(project, profile, {}) == values + alpha["properties"]["weights"]["x-fortran-shape"] = ":" + values["alpha"]["weights"] = [1.0, 2.0, 3.0] + save_profiles(project, [(profile, values, {})]) + assert load_profile(project, profile, {}) == values + + +def test_virtual_profiles_without_toml_profiles_and_output_safety(tmp_path): + _write_project(tmp_path) + config = tmp_path / "nml-config.toml" + config.write_text(config.read_text().split("[[file_profiles]]")[0]) + project = load_project(tmp_path) + assert not project.profiles + virtual = create_virtual_project(project, "custom", "custom.nml", ["alpha"]) + assert virtual.profiles[0].name == "custom" + with pytest.raises(ValueError, match="inside"): + create_virtual_project(project, "bad", "../outside.nml", ["alpha"]) + + +def test_external_project_profiles_cannot_escape_output_directory(tmp_path): + _write_project(tmp_path) + project = load_project(tmp_path) + path = tmp_path / "unsafe.toml" + path.write_text( + '[[file_profiles]]\nname = "unsafe"\ndefault_file = "../outside.nml"\n' + 'namelists = ["alpha"]\n\n[[project_profiles]]\nname = "unsafe"\n' + 'file_profiles = ["unsafe"]\n' + ) + with pytest.raises(ValueError, match="inside"): + load_project_profile_file(project, path) + + +def test_save_rejects_strings_that_fortran_would_truncate(tmp_path): + _write_project(tmp_path) + project = load_project(tmp_path) + with pytest.raises(ValueError, match="length"): + save_profiles( + project, + [ + ( + project.profile("main"), + { + "alpha": {"count": 1, "label": "x" * 33}, + }, + {}, + ) + ], + ) + assert not (tmp_path / "main.nml").exists() diff --git a/tests/test_gui_widgets.py b/tests/test_gui_widgets.py new file mode 100644 index 0000000..9bfd07e --- /dev/null +++ b/tests/test_gui_widgets.py @@ -0,0 +1,800 @@ +"""Offscreen checks of direct namelist editing and singleton fields.""" + +import os + +import pytest + +from nml_tools.gui.model import ( + GUI_REF_ORIGIN_KEY, + MISSING, + GuiProfile, + GuiProject, + GuiProjectProfile, + NamelistPage, +) +from nml_tools.schema import resolve_schema + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +pytest.importorskip("qtpy") +try: + from qtpy.QtCore import Qt + from qtpy.QtTest import QTest + from qtpy.QtWidgets import QApplication, QMessageBox +except ImportError: + pytest.skip("Qt binding unavailable", allow_module_level=True) + +from nml_tools.gui.app import ConfigurationDialog, ProfileConfigTab, ProfileTab +from nml_tools.gui.fields import ( + ArrayField, + FieldRow, + NamelistForm, + ObjectField, + ScalarField, +) + + +@pytest.fixture(scope="module") +def application(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def project(tmp_path): + schema = { + "type": "object", + "x-fortran-namelist": "run", + "properties": { + "count": {"type": "integer", "default": 3, "examples": [99]}, + "label": {"type": "string", "x-fortran-len": 16, "default": "default"}, + "weights": { + "type": "array", + "x-fortran-shape": "n", + "items": {"type": "number"}, + "default": [1.0], + "x-fortran-default-repeat": True, + }, + "periods": { + "type": "array", + "x-fortran-shape": "n", + "items": { + "type": "object", + "x-fortran-type": "period_t", + "properties": {"year": {"type": "integer", "default": 2000}}, + }, + }, + }, + } + page = NamelistPage("run", "run", schema) + profile = GuiProfile("main", "main", "Main", None, "run.nml", (page,)) + return GuiProject(tmp_path, {}, {"n": 1}, (profile,), namelists=(page,)) + + +def _add_project(dialog, key="default"): + index = next( + ( + index + for index in range(dialog.sourceComboBox_loadProject.count()) + if dialog.sourceComboBox_loadProject.itemData(index) == ("configured", key) + ), + -1, + ) + assert index >= 0 + dialog.sourceComboBox_loadProject.setCurrentIndex(index) + dialog._add_selected_project() + return dialog + + +def test_singletons_keep_array_values_and_restore_schema_defaults(application): + schema = {"type": "array", "x-fortran-shape": "n", "items": {"type": "integer", "default": 7}} + row = FieldRow("counts", schema, [8], {"n": 1}) + assert isinstance(row.field, ArrayField) + assert isinstance(row.field.inline, ScalarField) + assert row.field.button.isHidden() + row.field.inline.set_value(12) + assert row.value() == [12] + row.reset({"n": 2}) + assert row.field.inline is None + assert row.value() == [7, 7] + row.reset({"n": 1}) + assert isinstance(row.field.inline, ScalarField) + assert row.value() == [7] + deferred = FieldRow("counts", {**schema, "x-fortran-shape": ":"}, [8], {}) + assert isinstance(deferred.field.inline, ScalarField) + assert not deferred.field.button.isHidden() + + +def test_numeric_fields_use_appropriate_controls(application): + from qtpy.QtWidgets import QLineEdit, QSpinBox + + integer = ScalarField({"type": "integer", "minimum": -3, "maximum": 9}, 4) + assert isinstance(integer.control, QSpinBox) + assert (integer.control.minimum(), integer.control.maximum(), integer.value()) == (-3, 9, 4) + singleton = FieldRow( + "values", {"type": "array", "x-fortran-shape": 1, "items": {"type": "integer"}}, [2], {} + ) + assert isinstance(singleton.field.inline.control, QSpinBox) + + number = ScalarField({"type": "number", "minimum": 0.0, "maximum": 1.0}, 0.25) + assert isinstance(number.control, QLineEdit) + assert number.value() == 0.25 + number.control.setText("not-a-number") + with pytest.raises(ValueError, match="not a valid number"): + number.value() + + wide = ScalarField({"type": "integer", "maximum": 2**40}, 4) + assert isinstance(wide.control, QLineEdit) + + +def test_inline_array_title_and_hint_styles(application, monkeypatch): + messages = [] + monkeypatch.setattr(QMessageBox, "information", lambda *args: messages.append(args[1:])) + array = FieldRow( + "weights", + { + "title": "Body weights", + "description": "Weight for each body.", + "type": "array", + "x-fortran-shape": 2, + "items": {"type": "number"}, + }, + [1.0, 2.0], + {}, + required=True, + show_label=True, + ) + inline = array.field.table_editor + assert inline.titleLabel.text() == "Body weights (weights) *" + assert inline.separatorLine is not None and not inline.infoButton.isHidden() + assert array.fieldLabel.isHidden() and array.infoButton.isHidden() + assert inline.tableWidget.columnCount() == 2 + assert [inline.tableWidget.horizontalHeaderItem(i).text() for i in range(2)] == ["1", "2"] + assert inline.tableWidget.height() < 100 + assert inline.tableWidget.height() >= ( + inline.tableWidget.horizontalHeader().height() + + inline.tableWidget.rowHeight(0) + + inline.tableWidget.horizontalScrollBar().sizeHint().height() + ) + inline.infoButton.click() + assert messages == [("Body weights", "Weight for each body.")] + + aligned = NamelistForm( + { + "type": "object", + "properties": { + "short": {"type": "integer"}, + "long": {"title": "A much longer field label", "type": "string"}, + }, + }, + {"short": 1, "long": "value"}, + {}, + ) + aligned.show() + application.processEvents() + assert len({row.editorHost.geometry().x() for row in aligned.rows.values()}) == 1 + + default = FieldRow("label", {"type": "string", "default": "fallback"}, MISSING, {}) + example = FieldRow("note", {"type": "string", "examples": ["sample"]}, MISSING, {}) + assert default.field.control.placeholderText() == "fallback" + assert example.field.control.placeholderText() == "sample" + assert "#cc7070" in default.field.control.styleSheet() + assert "#888888" in example.field.control.styleSheet() + default.field.control.setText("changed") + default.field.control.textEdited.emit("changed") + default.field.control.clear() + default.field.control.textEdited.emit("") + assert "#cc7070" in default.field.control.styleSheet() + assert default.value() == "fallback" and example.value() is MISSING + QTest.keyClick(default.field.control, Qt.Key_Tab) + QTest.keyClick(example.field.control, Qt.Key_Tab) + assert default.field.control.text() == "fallback" + assert example.value() == "sample" + count = FieldRow("count", {"type": "integer", "default": 4}, MISSING, {}) + QTest.keyClick(count.field.control, Qt.Key_Tab) + assert count.field.control.prefix() == "" and count.field.modified + count.field.control.lineEdit().selectAll() + QTest.keyClick(count.field.control.lineEdit(), Qt.Key_Backspace) + assert count.field.control.lineEdit().text() == "" + assert count.field.control.lineEdit().placeholderText() == "4" + assert count.value() == 4 + + optional = FieldRow("optional", {"type": "integer", "minimum": 0}, 5, {}) + optional.field.control.lineEdit().selectAll() + QTest.keyClick(optional.field.control.lineEdit(), Qt.Key_Backspace) + assert optional.value() is MISSING + + flag = FieldRow("flag", {"type": "boolean", "default": False}, MISSING, {}) + assert flag.field.control.checkState() == Qt.Unchecked + assert flag.field.control.text() == flag.field.control.toolTip() == "False" + flag.field.control.click() + assert flag.field.control.isChecked() and not flag.field.control.text() + assert flag.field.control.toolTip() == "False" and not flag.field.control.styleSheet() + + default_array = FieldRow( + "counts", + { + "type": "array", + "x-fortran-shape": 2, + "items": {"type": "integer", "minimum": 0}, + "default": [1, 2], + }, + [9, 8], + {}, + ) + default_cell = default_array.field.table_editor.cells[(1,)].field.control + default_cell.lineEdit().selectAll() + QTest.keyClick(default_cell.lineEdit(), Qt.Key_Backspace) + assert default_cell.lineEdit().placeholderText() == "1" + assert default_array.value() == [1, 8] + + example_array = FieldRow( + "counts", + { + "type": "array", + "x-fortran-shape": 2, + "items": {"type": "integer", "minimum": 0}, + "examples": [[1, 2]], + }, + [9, 8], + {}, + ) + example_cell = example_array.field.table_editor.cells[(1,)].field.control + example_cell.lineEdit().selectAll() + QTest.keyClick(example_cell.lineEdit(), Qt.Key_Backspace) + assert example_cell.lineEdit().placeholderText() == "1" + assert example_array.value().assigned == {(2,)} + + labeled = NamelistForm( + { + "type": "object", + "properties": { + "codes": { + "type": "array", + "x-fortran-shape": 3, + "items": {"type": "number"}, + "axes": {"1": {"labels": ["X", "Y", "Z"]}}, + }, + "vector": { + "type": "array", + "x-fortran-shape": 3, + "items": {"type": "number"}, + "axes": {"1": {"labels": ["X", "Y", "Z"]}}, + }, + }, + }, + None, + {}, + ) + assert not labeled.tables + assert labeled.rows["vector"].field.table_editor.tableWidget.columnCount() == 3 + + +def test_derived_singletons_use_inline_object_fields(application, project): + schema = project.namelists[0].schema["properties"]["periods"] + row = FieldRow("periods", schema, [{"year": 2020}], {"n": 1}) + assert isinstance(row.field.inline, ObjectField) + row.field.inline.rows["year"].field.set_value(2025) + assert row.value() == [{"year": 2025}] + row.reset({"n": 1}) + assert row.value() == [{"year": 2000}] + scalar = ObjectField(schema["items"], {"year": 2021}, {}) + assert scalar.value() == {"year": 2021} + + +def test_path_and_date_time_fields(application, tmp_path, monkeypatch): + from qtpy.QtCore import QDateTime + + from nml_tools.gui import fields + from nml_tools.gui.fields import DateTimeField, PathField + + schema = { + "type": "object", + "properties": { + "path": {"type": "string", "format": "file-path"}, + "paths": { + "type": "array", + "x-fortran-shape": "n", + "items": {"type": "string", "format": "file-path"}, + }, + "when": {"type": "string", "format": "date-time"}, + }, + } + form = NamelistForm( + schema, + {"path": "old.nc", "paths": ["one.nc"], "when": "2025-01-01"}, + {"n": 1}, + output_root=tmp_path, + ) + path = form.rows["path"].field + assert isinstance(path, PathField) + assert isinstance(form.rows["paths"].field.inline, PathField) + monkeypatch.setattr( + fields.QFileDialog, + "getOpenFileName", + lambda *args: (str(tmp_path / "data" / "input.nc"), ""), + ) + path.browse.click() + assert path.value() == "data/input.nc" + + date_time = form.rows["when"].field + assert isinstance(date_time, DateTimeField) + assert date_time.value() == "2025-01-01" + date_time.control.setDateTime(QDateTime.fromString("2026-02-03 04:05", "yyyy-MM-dd HH:mm")) + assert date_time.value() == "2026-02-03 04:05" + hinted_row = FieldRow( + "when", + {"type": "string", "format": "date-time", "examples": ["2027-03-04 05:06"]}, + MISSING, + {}, + ) + hinted = hinted_row.field + QTest.keyClick(hinted.control, Qt.Key_Tab) + assert hinted.modified and hinted.value() == "2027-03-04 05:06" + + +def test_dialog_load_overlay_save_reload_and_dimension_changes(application, project, monkeypatch): + errors = [] + monkeypatch.setattr(QMessageBox, "critical", lambda *args: errors.append(args[-1])) + monkeypatch.setattr(QMessageBox, "question", lambda *args: QMessageBox.Yes) + path = project.root / "run.nml" + path.write_text('&run count=8 label="saved" weights(1)=2.0 periods(1)%year=2021 /\n') + dialog = _add_project( + ConfigurationDialog(project, initial_values={"main": {"run": {"count": 9}}}) + ) + editor = dialog.editors[path] + assert editor.values()["run"]["count"] == 9 + assert editor.values()["run"]["label"] == "saved" + editor.forms["run"].rows["periods"].field.inline.rows["year"].field.set_value(2025) + dialog._save_all() + assert "periods(1)%year = 2025" in path.read_text() + dialog.config_tab.dimension_boxes["n"].setValue(2) + dialog.config_tab.run.click() + assert dialog.editors[path].forms["run"].rows["weights"].field.inline is None + assert dialog.editors[path].values()["run"]["weights"] == [2.0, 1.0] + resized = dialog.editors[path] + assert resized.saved_dimensions == {"n": 1} + resized.forms["run"].rows["count"].field.set_value(77) + resized.cancel.click() + assert resized.parent() is None + assert dialog.project_dimensions["default"] == dialog.editors[path].dimensions == {"n": 2} + assert dialog.editors[path].values()["run"]["weights"] == [2.0, 1.0] + dialog._save_all() + loaded = _add_project(ConfigurationDialog(project)) + assert loaded.config_tab.dimension_boxes["n"].value() == 2 + loaded.close() + dialog.config_tab.dimension_boxes["n"].setValue(1) + dialog.config_tab.run.click() + assert isinstance(dialog.editors[path].forms["run"].rows["weights"].field.inline, ScalarField) + dialog._save_all() + reloaded = _add_project(ConfigurationDialog(project)) + assert reloaded.editors[path].values()["run"]["periods"] == [{"year": 2025}] + dialog.tabs.setCurrentWidget(dialog.plus_tab) + builder = dialog.tabs.currentWidget() + assert isinstance(builder, ProfileConfigTab) + builder.profile_name.setText("extra") + builder.default_filename.setText("extra.nml") + dialog._move_all(builder.available_schemas, builder.selected_schemas) + builder.run.click() + assert project.root / "extra.nml" in dialog.editors + dialog._save_all() + assert (project.root / "extra.nml").is_file() + extra = dialog.editors[project.root / "extra.nml"] + extra.forms["run"].rows["count"].field.set_value(17) + index = next( + i + for i in range(builder.source_combo.count()) + if builder.source_combo.itemData(i) == ("profile", "extra") + ) + assert index >= 0 + builder.source_combo.setCurrentIndex(index) + builder.default_filename.setText("extra-renamed.nml") + builder.run.click() + renamed = project.root / "extra-renamed.nml" + assert dialog.editors[renamed].values()["run"]["count"] == 17 + assert [item.key for item in dialog.loaded_projects["default"].profiles].count("extra") == 1 + assert 'default_file = "extra-renamed.nml"' in (project.root / "default.toml").read_text() + assert not list(project.root.glob("*.json")) + assert not errors + dialog.close() + reloaded.close() + + +def test_invalid_existing_input_is_reported_and_never_replaced(application, project, monkeypatch): + errors = [] + monkeypatch.setattr(QMessageBox, "critical", lambda *args: errors.append(args[-1])) + path = project.root / "run.nml" + invalid = '&run count="wrong type" /' + path.write_text(invalid) + dialog = ConfigurationDialog(project) + assert not errors and not dialog.editors + _add_project(dialog) + assert errors and not dialog.editors + dialog._save_all() + assert path.read_text() == invalid + dialog.close() + + +def test_guidata_derived_edits_commit(application): + np = pytest.importorskip("numpy") + pytest.importorskip("guidata") + from guidata.widgets.arrayeditor import ArrayEditor + + from nml_tools.gui.fields import _derived_array_editor + + data = np.array([(2000,)], dtype=[("year", "i4")]).reshape(1, 1) + editor = _derived_array_editor(ArrayEditor, None) + try: + assert editor.setup_and_check(data) + editor._data.current_changes[("year", 0, 0)] = 2025 + editor.accept() + assert data["year"][0, 0] == 2025 + finally: + editor.close() + + +def test_guidata_path_update_and_date_time_editor(application): + np = pytest.importorskip("numpy") + pytest.importorskip("guidata") + from guidata.widgets.arrayeditor import ArrayEditor + from qtpy.QtCore import QDateTime + from qtpy.QtWidgets import QDateTimeEdit + + from nml_tools.gui.fields import _add_path_array_controls, _install_date_time_delegate + + data = np.array([["a.nc", "b.nc"]], dtype="U1024") + editor = ArrayEditor(None) + try: + assert editor.setup_and_check(data) + line, _, update = _add_path_array_controls(editor, editor) + editor.arraywidget.view.selectAll() + line.setText("data/input.nc") + update.click() + model = editor.arraywidget.model + assert model.get_value((0, 0)) == model.get_value((0, 1)) == "data/input.nc" + + _install_date_time_delegate(editor) + view = editor.arraywidget.view + index = view.model().index(0, 0) + delegate = view.itemDelegate() + control = delegate.createEditor(view, None, index) + assert isinstance(control, QDateTimeEdit) + control.setDateTime(QDateTime.fromString("2026-02-03 04:05", "yyyy-MM-dd HH:mm")) + delegate.setModelData(control, view.model(), index) + assert model.get_value((0, 0)) == "2026-02-03 04:05" + finally: + editor.close() + + +def test_profile_sources_exclude_namelists_and_include_project_toml(application, project): + path = project.root / "external.nml" + path.write_text("&run count=42 weights(3)=9.0 /\n") + (project.root / "external.toml").write_text( + '[[file_profiles]]\nname = "copy"\ndefault_file = "copy.nml"\n' + 'namelists = ["run"]\n\n[[project_profiles]]\nname = "external"\n' + 'file_profiles = ["copy"]\n' + ) + dialog = _add_project(ConfigurationDialog(project)) + dialog.tabs.setCurrentWidget(dialog.plus_tab) + config = dialog.tabs.currentWidget() + assert config.source_combo.findData(str(path)) < 0 + assert any( + config.source_combo.itemData(index) == ("profile", "copy") + for index in range(config.source_combo.count()) + ) + index = next( + index + for index in range(config.source_combo.count()) + if config.source_combo.itemData(index) == ("profile", "copy") + ) + config.source_combo.setCurrentIndex(index) + assert config.profile_name.text() == "copy" + config.source_combo.setCurrentIndex(0) + assert not config.profile_name.text() and not config.default_filename.text() + assert config.selected_schemas.count() == 0 + dialog.close() + + +def test_browsed_project_source_loads(application, project, monkeypatch): + from nml_tools.gui import app + + path = project.root / "external.toml" + path.write_text( + '[[file_profiles]]\nname = "copy"\ndefault_file = "copy.nml"\n' + 'namelists = ["run"]\n\n[[project_profiles]]\nname = "external"\n' + 'file_profiles = ["copy"]\n' + ) + monkeypatch.setattr(app.QFileDialog, "getOpenFileName", lambda *args: (str(path), "")) + dialog = ConfigurationDialog(project) + dialog._browse_project() + assert dialog.sourceComboBox_loadProject.currentData() == ("path", str(path.resolve())) + dialog._add_selected_project() + assert "external" in dialog.loaded_projects + dialog.close() + + +def test_public_launch_forwards_profile_selection(application, monkeypatch, tmp_path): + from nml_tools.gui import app, launch_gui + + calls = [] + monkeypatch.setattr(app, "launch_gui", lambda *args: calls.append(args) or 0) + selected = {"main": ["run"]} + values = {"main": {"run": {"count": 8}}} + assert launch_gui(tmp_path, tmp_path / "out", selected, values, {"n": 1}) == 0 + assert calls == [(tmp_path, tmp_path / "out", selected, values, {"n": 1})] + + +def test_active_and_last_config_tabs_close(application, project): + dialog = _add_project(ConfigurationDialog(project)) + assert dialog.projectSourceLayout.stretch(0) == 1 + assert dialog.projectSourceLayout.stretch(1) == 2 + assert dialog.browseButton_addProject.text() == "+" + assert dialog.treeWidget_projectStructure.maximumWidth() > 275 + assert dialog.tabs.tabText(dialog.tabs.indexOf(dialog.config_tab)) == "⚙️" + assert next(iter(dialog.editors.values())).label.text() == "Selected Namelist:" + editor = next(iter(dialog.editors.values())) + dialog.tabs.setCurrentWidget(dialog.config_tab) + dialog.tabs.setCurrentWidget(editor) + selected = dialog.treeWidget_projectStructure.currentItem() + assert selected.data(0, Qt.ItemDataRole.UserRole)[:3] == ("profile", "default", "main") + assert selected.isExpanded() + dialog.tabs.setCurrentWidget(dialog.plus_tab) + builder = dialog.tabs.currentWidget() + dialog._close_tab(dialog.tabs.indexOf(builder)) + assert dialog.tabs.indexOf(builder) == -1 + dialog._close_tab(dialog.tabs.indexOf(dialog.config_tab)) + assert dialog.config_tab is None + dialog._close_tab(dialog.tabs.indexOf(next(iter(dialog.editors.values())))) + last = dialog.config_tab + dialog._close_tab(dialog.tabs.indexOf(last)) + assert dialog.config_tab is not last and dialog.tabs.count() == 2 + dialog.close() + + +def test_inactive_projects_stay_hidden_and_tree_fills_splitter(application, project): + from dataclasses import replace + + profile = project.profiles[0] + project = replace( + project, + project_profiles=( + GuiProjectProfile("one", "one", "One", None, (profile,), custom=True), + GuiProjectProfile("two", "two", "Two", None, (profile,), custom=True), + ), + ) + dialog = ConfigurationDialog(project) + assert not dialog.loaded_projects + assert dialog.treeWidget_projectStructure.topLevelItemCount() == 0 + _add_project(dialog, "one") + _add_project(dialog, "two") + dialog.treeWidget_projectStructure.setCurrentItem(dialog.project_items["one"]) + dialog.show() + dialog.splitter.setSizes([400, 600]) + application.processEvents() + tree = dialog.treeWidget_projectStructure + assert tree.width() == dialog.splitter.sizes()[0] + assert tree.columnCount() == 2 and tree.columnWidth(1) == 28 + assert sum(tree.columnWidth(i) for i in range(2)) == tree.viewport().width() + assert not dialog.project_configs["two"].isVisible() + assert not any(editor.isVisible() for editor in dialog.project_editors["two"].values()) + file_item = tree.topLevelItem(0).child(0) + assert not file_item.isExpanded() + tree.setCurrentItem(file_item) + assert file_item.isExpanded() + dialog.close() + + +def test_explicit_project_profiles_are_preloaded(application, project): + from dataclasses import replace + + profile = project.profiles[0] + project = replace( + project, + project_profiles=( + GuiProjectProfile("one", "one", "One", None, (profile,)), + GuiProjectProfile("two", "two", "Two", None, (profile,)), + ), + ) + dialog = ConfigurationDialog(project, preload_projects=True) + assert list(dialog.loaded_projects) == ["one", "two"] + assert dialog.treeWidget_projectStructure.topLevelItemCount() == 2 + assert dialog.active_project == "one" + dialog.close() + + +def test_unopened_page_repeats_array_defaults(application, tmp_path): + from nml_tools.gui.model import save_profiles + + first = NamelistPage( + "first", + "first", + { + "type": "object", + "x-fortran-namelist": "first", + "properties": {"enabled": {"type": "boolean", "default": False}}, + }, + ) + geology = NamelistPage( + "geoparameter", + "geoparameter", + { + "type": "object", + "x-fortran-namelist": "geoparameter", + "properties": { + "GeoParam": { + "type": "array", + "items": {"type": "number"}, + "x-fortran-shape": ["n_geo_units", 5], + "default": [1.0, 1000.0, 100.0, 1, 1], + "x-fortran-default-repeat": True, + } + }, + "required": ["GeoParam"], + }, + ) + profile = GuiProfile( + "parameter", "parameter", "Parameter", None, "parameters.nml", (first, geology) + ) + project = GuiProject( + tmp_path, + {}, + {"n_geo_units": 2}, + (profile,), + namelists=(first, geology), + ) + tab = ProfileTab(project, profile, {}, {"n_geo_units": 2}) + assert "geoparameter" not in tab.forms + values = tab.values() + assert [len(row) for row in values["geoparameter"]["GeoParam"]] == [5, 5] + save_profiles(project, [(profile, values, {"n_geo_units": 2})]) + assert (tmp_path / "parameters.nml").read_text().count("GeoParam(") == 10 + + +def test_shared_reference_table_edit_reset_and_round_trip(application, tmp_path): + from nml_tools.gui.model import load_profile, recover_dimensions, save_profiles + + schema = resolve_schema( + { + "type": "object", + "x-fortran-namelist": "run", + "$defs": { + "period": { + "type": "object", + "x-fortran-type": "period_t", + "properties": { + "year": {"type": "integer", "default": 2000}, + "enabled": {"type": "boolean", "default": False}, + }, + "required": ["year"], + } + }, + "properties": { + "start": { + "$ref": "#/$defs/period", + "title": "Starting evaluation period", + "description": "First configured period.", + "default": {"year": 2001}, + }, + "stop": {"$ref": "#/$defs/period", "default": {"year": 2002}}, + "periods": { + "type": "array", + "x-fortran-shape": "n", + "items": {"$ref": "#/$defs/period"}, + }, + }, + } + ) + form = NamelistForm(schema, None, {"n": 2}) + form.resize(800, 600) + (table,) = form.tables + form.show() + application.processEvents() + assert table.y() < 50 + assert table.cellWidget(0, 0).isVisible() + assert (table.rowCount(), table.columnCount()) == (4, 3) + assert table.horizontalHeaderItem(0).text() == "Namelist Property" + assert table.horizontalHeaderItem(1).text() == "year *" + assert table.cellWidget(0, 0).fieldLabel.text() == "start *" + assert not table.cellWidget(0, 0).infoButton.isHidden() + table.objects["periods", (1,)].rows["year"].field.set_value(2025) + table.objects["periods", (1,)].rows["enabled"].field.set_value(True) + values = form.values() + assert values["periods"][1] == {"year": 2025, "enabled": True} + page = NamelistPage("run", "run", schema) + profile = GuiProfile("main", "main", "Main", None, "run.nml", (page,)) + project = GuiProject(tmp_path, {}, {"n": 100}, (profile,), namelists=(page,)) + save_profiles(project, [(profile, {"run": values}, {"n": 2})]) + assert load_profile(project, profile, recover_dimensions(project)) == {"run": values} + form.reset() + assert form.values()["start"]["year"] == 2001 + assert form.values()["stop"]["year"] == 2002 + assert form.values()["periods"][1]["year"] == 2000 + for name, indices in (("start", ()), ("periods", (0,))): + single = NamelistForm( + {**schema, "properties": {name: schema["properties"][name]}}, None, {"n": 1} + ) + (single_table,) = single.tables + assert (single_table.rowCount(), single_table.columnCount()) == (1, 3) + assert single_table.horizontalHeaderItem(1).text() == "year *" + defaults = single.values() + single_table.objects[name, indices].rows["year"].field.set_value(2035) + edited = {"year": 2035, "enabled": False} + assert single.values()[name] == ([edited] if indices else edited) + single.reset() + assert single.values() == defaults + + +def test_required_derived_array_writes_item_defaults(application, tmp_path): + from nml_tools.gui.model import save_profiles + + schema = resolve_schema( + { + "type": "object", + "x-fortran-namelist": "baseflow_1", + "$defs": { + "parameter": { + "type": "object", + "x-fortran-type": "parameter_t", + "properties": { + "value": {"type": "number"}, + "optimize": {"type": "boolean", "default": False}, + "min": {"type": "number"}, + "max": {"type": "number"}, + }, + "required": ["value", "min", "max"], + } + }, + "properties": { + "baseflow_recession": { + "type": "array", + "x-fortran-shape": "n_geo_units", + "items": { + "$ref": "#/$defs/parameter", + "default": {"value": 100.0, "min": 1.0, "max": 1000.0}, + }, + } + }, + "required": ["baseflow_recession"], + } + ) + form = NamelistForm(schema, None, {"n_geo_units": 2}) + values = form.values() + assert values["baseflow_recession"].assigned == {(1,), (2,)} + page = NamelistPage("baseflow_1", "baseflow_1", schema) + profile = GuiProfile("parameters", "parameters", "Parameters", None, "parameters.nml", (page,)) + project = GuiProject(tmp_path, {}, {"n_geo_units": 2}, (profile,), namelists=(page,)) + save_profiles(project, [(profile, {"baseflow_1": values}, {"n_geo_units": 2})]) + text = (tmp_path / "parameters.nml").read_text() + assert "baseflow_recession(1)%value = 100.0" in text + assert "baseflow_recession(2)%max = 1000.0" in text + + +def test_single_referenced_array_uses_top_aligned_resizable_table(application): + schema = { + "title": "Long descriptive parameter title", + "description": "Parameter explanation.", + "type": "array", + "x-fortran-shape": 2, + "items": {"type": "number"}, + "default": [75.0, 200.0], + "examples": [[75.0, 200.0]], + GUI_REF_ORIGIN_KEY: "parameter.yml#/$defs/parameter", + } + form = NamelistForm( + {"type": "object", "properties": {"factor": schema}, "required": ["factor"]}, + None, + {}, + ) + form.show() + application.processEvents() + (table,) = form.tables + assert not form.rows and table.y() < 50 + assert table.cellWidget(0, 0).fieldLabel.text() == "factor *" + assert not table.cellWidget(0, 0).infoButton.isHidden() + assert table.horizontalHeaderItem(0).text() == "Namelist Property" + assert table.horizontalHeader().sectionResizeMode(0) == table.horizontalHeader().Interactive + assert table.horizontalHeader().minimumSectionSize() == 88 + assert table.columnWidth(0) >= 180 and table.columnWidth(1) >= 88 + table.rows["factor"][0].set_value(12.0, {}) + table.reset() + control = table.rows["factor"][0].field.control + assert control.placeholderText() == "75.0" + assert table.values()["factor"] == [75.0, 200.0] diff --git a/tests/test_validate.py b/tests/test_validate.py index c0f60bc..c0f0474 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -410,6 +410,22 @@ def test_null_only_required_value_does_not_establish_presence() -> None: _evaluate(schema, "settings = 2*") +def test_evaluator_tracks_value_span_separately_from_trailing_null() -> None: + schema = { + "x-fortran-namelist": "run", + "type": "object", + "properties": {"count": {"type": "integer"}}, + } + result = _evaluate(schema, "count=2\ncount=-1\ncount=,") + state = result.states[("count", (), None)] + assert state.value == -1 + assert state.source_span is not None + assert state.source_span.start.line == 4 + assert state.value_source_span is not None + assert state.value_source_span.start.line == 3 + assert state.value_source_span.start.column == 7 + + def test_defaults_satisfy_required_input_and_remain_sparse() -> None: schema = { "x-fortran-namelist": "run", @@ -434,6 +450,7 @@ def test_defaults_satisfy_required_input_and_remain_sparse() -> None: assert state.initialized_by_default is True assert state.explicitly_assigned is False assert state.null_consumed is True + assert state.value_source_span is None string_schema = { "x-fortran-namelist": "run",