Skip to content

Make Input, Output, ModelSpec instances immutable.#2537

Open
davemfish wants to merge 9 commits intonatcap:mainfrom
davemfish:bugfix/2228-immutable-spec
Open

Make Input, Output, ModelSpec instances immutable.#2537
davemfish wants to merge 9 commits intonatcap:mainfrom
davemfish:bugfix/2228-immutable-spec

Conversation

@davemfish
Copy link
Copy Markdown
Contributor

@davemfish davemfish commented Apr 30, 2026

Added a Parent class for shared config.

There's an interesting case during validation where we do intentionally modify the required attribute because it needs to be evaluated dynamically, before a subsequent call to validation. It seemed cleaner to override the immutability than to create copies of all the nested specs, update them, and copy the parent spec and update it with the new list of nested specs, before calling the parent's validate method.

This only came up for Inputs with nested specs, because for the Input itself, while it can have a required expression that needs evaluating, there is no need to update the original required value after evaluating the expression.

Fixes #2228

Checklist

  • Updated HISTORY.rst and link to any relevant issue (if these changes are user-facing)
  • Updated the user's guide (if needed)
  • Tested the Workbench UI (if relevant)

@davemfish davemfish requested a review from emlys April 30, 2026 15:40
Copy link
Copy Markdown
Member

@emlys emlys left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling this @davemfish! I had avoided the immutability question myself when first developing this because of the difficulty with evaluating the required strings. Just a few comments

Comment thread src/natcap/invest/spec.py Outdated


class Input(BaseModel):
class Parent(BaseModel):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this have a more descriptive name like ImmutableBaseModel?

Comment thread src/natcap/invest/validation.py Outdated
# Using deepcopy to make sure we don't modify the original spec
parameter_spec = copy.deepcopy(model_spec.get_input(key))
# Using a deep copy to make sure we don't modify the original spec
# spec or original nested specs.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is "spec" repeated an extra time in this sentence?

Comment thread src/natcap/invest/spec.py Outdated
col_spec.required = bool(utils.evaluate_expression(
# attributes have faux immutability; we cannot assign
# to them, but we can assign to the underlying dict.
col_spec.__dict__['required'] = bool(utils.evaluate_expression(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice to know that we can get around immutability like this if needed! Would it be worth implementing some sort of copy-with-update function to create a new instance with the changed attribute? Given that it's only used in one place, maybe it's not worth it, but it would be nice to respect immutability if possible.

Copy link
Copy Markdown
Contributor Author

@davemfish davemfish May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pydantic provides model_copy(update={}) so it's probably worth using in this spot. I made that update. In the similar, but more complicated, situation in validation.py it did not seem worth it to copy and replace all the objects because of how they are nested.

Comment thread src/natcap/invest/spec.py
number=count))
header=f'{self.orientation}(s)',
header_name=','.join(duplicated_columns),
number='multiple'))
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block is unrelated to the rest of the PR, but I noticed these variables were not defined. I think this code basically never runs because if pandas reads a CSV with duplicate column names, it adds a suffix to the names as needed. I think the only way to get here is when the orientation is "rows", but then we transpose the table and rows become columns, then there can be duplicate columns at that point.

There can be multiple duplicate columns, so it makes for a complicated message to try to describe the count of each duplicate. I think it's more helpful to just have the message read:

'Expected the row(s) "1, 2" only once but found it multiple times'
or
'Expected the row(s) "1" only once but found it multiple times'

Still not ideal, but this message is also used elsewhere so I didn't want to change the template.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec.Input should be immutable (pydantic "frozen")

2 participants