ansys / ansys/pydynamicreporting
Add safe replacement of file-backed item content
- Dominant language
- Python
- Stars
- 12
- Forks
- 5
- Avg merge
- 3d 14h
- Merged PRs (30d)
- 9
Description
### 📝 Description of the feature
Add a supported, failure-safe API for replacing the managed content of an existing file-backed serverless item.
`Image`, `Animation`, `Scene`, and `File` validate a source path during construction and copy or convert it into the configured media directory when first saved. After the object is persisted, assigning a new path to `content` and calling `save()` is not a reliable replacement contract.
The current common save path derives a managed filename and deliberately skips the write when that target already exists:
```python
@staticmethod
def _save_file(target_path, content):
if Path(target_path).is_file():
return
...
def save(self, **kwargs):
self._orm_instance.payloadfile = f"{self.guid}_{self.type}.{self._file_ext}"
self._save_file(self.file_path, self._file)
super().save(**kwargs)
```
Consequences for an existing item can include:
- same-extension replacement keeping the old managed bytes because the target filename already exists;
- extension changes creating a new managed path without an explicit old-file cleanup contract;
- image conversions reusing an existing `_image.png` target;
- stale image metadata if replacement state is not fully recomputed;
- stale or orphaned derived 3D geometry for `Scene` and geometry-capable `File` items;
- database and filesystem state diverging when an error occurs between file and ORM updates.
Creating a new item is not always an acceptable substitute: callers may need to preserve the item GUID, template filters, external references, session/dataset association, tags, and audit identity.
PyDynamicReporting owns the media naming, validation, conversion, cleanup, and 3D-derivation rules. Content replacement should therefore be a product API rather than a downstream sequence of private filesystem operations.
### 💡 Steps for implementing the feature
#### 1. Add a public method to file-backed item classes
Implement the operation on the shared file payload behavior so all four concrete item types expose it:
```python
class FilePayloadMixin:
def replace_content(
self,
source: str | Path,
*,
using: str | None = None,
) -> Item:
"""Replace managed payload content while preserving the item identity."""
```
Optionally expose an ADR convenience wrapper:
```python
def replace_item_content(
self,
item: Image | Animation | Scene | File,
source: str | Path,
) -> Item:
return item.replace_content(source)
```
The operation must preserve the item's GUID, type, name, tags, source, sequence, session, and dataset. Return a refreshed item whose `content`, `file_path`, `file_ext`, and type-specific metadata reflect persisted state.
Calling this method on `String`, `HTML`, `Table`, or `Tree` should either be impossible by API shape or raise a clear unsupported-operation exception.
#### 2. Reuse the concrete content validator before touching stored state
Validate the new source through the same descriptor used for creation:
- path exists and is a regular readable file;
- source is non-empty;
- extension is allowed for the concrete type;
- image content is readable and enhanced-image metadata is valid;
- all type-specific transformations can complete.
Do not assign partially validated state to the persisted object. Build a replacement plan containing:
```python
@dataclass(frozen=True)
class FileReplacementPlan:
source: Path
staged_path: Path
final_path: Path
final_extension: str
metadata: Mapping[str, object]
derived_paths: tuple[Path, ...]
```
Stage converted output in the same filesystem/directory as the final managed file so `os.replace()` is atomic.
#### 3. Coordinate filesystem and database failure handling
A database transaction cannot roll back filesystem writes by itself. Preserve the previous payload until the ORM update commits and provide compensation on every failure path.
Suggested sequence:
```python
old_path = Path(item.file_path)
plan = build_and_validate_replacement(item, source)
backup_path = unique_backup_path(old_path)
with transaction.atomic(using=database_alias):
try:
if plan.final_path == old_path and old_path.exists():
os.replace(old_path, backup_path)
os.replace(plan.staged_path, plan.final_path)
update_payloadfile_and_metadata(item, plan)
save_orm_state(item, using=database_alias)
except Exception:
plan.staged_path.unlink(missing_ok=True)
plan.final_path.unlink(missing_ok=True)
if backup_path.exists():
os.replace(backup_path, old_path)
raise
backup_path.unlink(missing_ok=True)
remove_obsolete_managed_paths(old_path, keep=plan.final_path)
return refetch_item(item.guid, using=database_alias)
```
The concrete implementation should also handle process interruption and Windows replacement semantics. Temporary and backup names must stay inside the managed media directory and include the item GUID.
If the final filename changes, keep the old path until the database points to the new path successfully. On rollback, the original ORM value and original bytes must remain readable.
#### 4. Recompute type-specific state
For `Image`:
- rerun image decoding and enhanced-image detection;
- recompute width, height, enhanced status, and final format;
- preserve the existing JPEG-to-PNG conversion behavior;
- replace an existing GUID-based PNG rather than silently skipping it.
For `Scene` and geometry-capable `File`:
- invalidate derived AVZ/geometry directories belonging to the old payload;
- rebuild derived geometry from the replacement;
- stage derived output where possible;
- restore the previous payload and derived data if rebuilding fails.
For `Animation` and generic `File`:
- enforce the concrete extension policy;
- remove obsolete managed payloads only after success.
Never delete arbitrary user source files. Cleanup is limited to PyDynamicReporting-managed media and derivative paths for the target GUID.
#### 5. Define no-op and conflict behavior
- Replacing with byte-identical content may return successfully without rewriting after validation, but this optimization is optional.
- Replacing with the current managed `file_path` must not truncate or delete the active payload.
- An unsaved item must raise `NotSaved` or use normal first-save behavior, as documented.
- Cross-database aliases and media roots must be rejected or handled explicitly.
- Concurrent replacements of the same GUID should serialize through a database row lock or fail with a documented conflict.
#### 6. Add focused tests
Cover each concrete file-backed type and at least:
1. same-extension replacement changes persisted bytes;
2. different-extension replacement updates the ORM path and removes only the obsolete managed file;
3. replacement preserves GUID and all non-content fields;
4. invalid, missing, empty, and unsupported-extension sources leave old bytes and ORM state unchanged;
5. image replacement recomputes dimensions, enhanced status, and conversion output;
6. scene/file replacement invalidates and rebuilds derived geometry;
7. injected conversion, filesystem, database-save, and geometry-build failures restore the original state;
8. no temporary, backup, old payload, or derived orphan remains after success;
9. concurrent replacement behavior is deterministic;
10. Windows and POSIX replacement paths follow the documented contract.
#### Acceptance criteria
- Existing file-backed items can replace content without changing GUID or unrelated metadata.
- Same-extension replacement cannot silently retain the old bytes.
- Extension changes cannot leave stale database paths or orphan managed payloads.
- Image and 3D-derived state is regenerated from the new source.
- Every failure path preserves a usable original item and removes staged artifacts.
- Cleanup never touches caller-owned source files.
- The operation is public, documented, typed, and tested for `Image`, `Animation`, `Scene`, and `File`.
### 🔗 Useful links and references
- [File content validation and extension policies](https://github.com/ansys/pydynamicreporting/blob/d98b45adad663d015d4e17ad93dcd3749f2f3d81/src/ansys/dynamicreporting/core/serverless/item.py#L250-L327)
- [Current managed-file save behavior](https://github.com/ansys/pydynamicreporting/blob/d98b45adad663d015d4e17ad93dcd3749f2f3d81/src/ansys/dynamicreporting/core/serverless/item.py#L369-L455)
- [Image conversion path](https://github.com/ansys/pydynamicreporting/blob/d98b45adad663d015d4e17ad93dcd3749f2f3d81/src/ansys/dynamicreporting/core/serverless/item.py#L819-L895)
- [Scene and generic-file geometry rebuild behavior](https://github.com/ansys/pydynamicreporting/blob/d98b45adad663d015d4e17ad93dcd3749f2f3d81/src/ansys/dynamicreporting/core/serverless/item.py#L908-L975)
An open-and-closed issue audit found no existing issue requesting replacement of persisted file-backed item content.
Contributor guide
Assessment
This issue has not been assessed yet.