diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6b101ff4..190be1a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,8 +4,13 @@ All notable changes to this project will be documented in this file.
## [0.30.3 - 2026-08-03]
+### Added
+
+- `FsNode.etag_unquoted` returning the entity tag without the double quotes the server wraps it in, for comparing or storing the bare value. `FsNode.etag` keeps what the server sent, so it can still be passed to an `If-Match`/`If-None-Match` header unchanged. #448 Thanks to @kyteinsky
+
### Fixed
+- `FsNode.etag` is always a string now; trashbin entries used to yield `None`, because the server sends an empty `` there. #448
- PROPFIND property lists are no longer mutated in place. `get_propfind_properties()` and both `trashbin_list()` implementations extended the shared `PROPFIND_PROPERTIES` constant with `+=`, so it grew on every call (7 entries per call against servers advertising `files.locking`, 3 per `trashbin_list()`). Long-running clients ended up sending multi-megabyte PROPFIND bodies that could exhaust the server's workers. Both property constants are now immutable tuples, so this class of bug cannot come back. #453 Thanks to @ciberkids
## [0.30.2 - 2026-06-02]
diff --git a/nc_py_api/files/__init__.py b/nc_py_api/files/__init__.py
index e6a986f3..e089c7b0 100644
--- a/nc_py_api/files/__init__.py
+++ b/nc_py_api/files/__init__.py
@@ -210,7 +210,12 @@ class FsNode:
"""File ID + NC instance ID"""
etag: str
- """An entity tag (ETag) of the object"""
+ """An entity tag (ETag) of the object, exactly as the server sent it, including the double quotes around it.
+
+ Send it back as-is, e.g. ``{"If-Match": fs_node.etag}``: the quotes are part of the entity tag
+ (:rfc:`9110#section-8.8.3`), and a server rejects the precondition without them.
+ Use :py:attr:`~nc_py_api.files.FsNode.etag_unquoted` to compare or store the bare value.
+ """
info: FsNodeInfo
"""Additional extra information for the object"""
@@ -221,7 +226,8 @@ class FsNode:
def __init__(self, full_path: str, **kwargs):
self.full_path = full_path
self.file_id = kwargs.get("file_id", "")
- self.etag = kwargs.get("etag", "")
+ # the trashbin sends an empty ``, which arrives here as None
+ self.etag = kwargs.get("etag") or ""
self.info = FsNodeInfo(**kwargs)
self.lock_info = FsNodeLockInfo(**kwargs)
@@ -230,6 +236,14 @@ def is_dir(self) -> bool:
"""Returns ``True`` for the directories, ``False`` otherwise."""
return self.full_path.endswith("/")
+ @property
+ def etag_unquoted(self) -> str:
+ """:py:attr:`~nc_py_api.files.FsNode.etag` without the surrounding double quotes.
+
+ For comparing or storing the bare tag; use :py:attr:`~nc_py_api.files.FsNode.etag` in request headers.
+ """
+ return self.etag.strip('"')
+
def __str__(self):
if self.info.is_version:
return (
diff --git a/tests/actual_tests/files_test.py b/tests/actual_tests/files_test.py
index 66440acf..c42b6df7 100644
--- a/tests/actual_tests/files_test.py
+++ b/tests/actual_tests/files_test.py
@@ -1300,3 +1300,20 @@ async def test_file_locking_async(anc_any):
with pytest.raises(NextcloudException) as e:
await anc_any.files.unlock(test_file)
assert e.value.status_code == 412
+
+
+def test_etag_is_accepted_by_server_as_is(nc_any):
+ """`FsNode.etag` must be usable in a request header without the caller touching it."""
+ nc_any.files.delete("test_etag_as_is.txt", not_fail=True)
+ node = nc_any.files.upload("test_etag_as_is.txt", b"content")
+ listed = nc_any.files.by_path("test_etag_as_is.txt")
+ assert node.etag == listed.etag
+ assert listed.etag_unquoted == listed.etag.strip('"')
+ dav_path = f"/files/{nc_any.user}/test_etag_as_is.txt"
+ unchanged = nc_any._session.adapter_dav.request("GET", dav_path, headers={"If-None-Match": listed.etag})
+ assert unchanged.status_code == 304
+ overwritten = nc_any._session.adapter_dav.request(
+ "PUT", dav_path, data=b"new content", headers={"If-Match": listed.etag}
+ )
+ assert overwritten.status_code in (200, 204)
+ nc_any.files.delete("test_etag_as_is.txt", not_fail=True)
diff --git a/tests_unit/test_etag_normalization.py b/tests_unit/test_etag_normalization.py
new file mode 100644
index 00000000..2dcaff12
--- /dev/null
+++ b/tests_unit/test_etag_normalization.py
@@ -0,0 +1,88 @@
+"""Tests for FsNode.etag: kept exactly as the server sent it, with a bare variant next to it."""
+
+from nc_py_api.files import ActionFileInfo, FsNode
+from nc_py_api.files._files import _parse_record, etag_fileid_from_response
+
+
+class _FakeResponse:
+ def __init__(self, headers: dict):
+ self.headers = headers
+
+
+def _prop_stat(etag) -> dict:
+ return {
+ "d:status": "HTTP/1.1 200 OK",
+ "d:prop": {"oc:id": "00000123", "oc:fileid": "123", "oc:permissions": "RGDNVW", "d:getetag": etag},
+ }
+
+
+def test_etag_is_kept_as_the_server_sent_it():
+ # the quotes are part of the entity tag, so `etag` stays usable in a request header as-is
+ assert FsNode("files/admin/a.txt", etag='"6a351fb28bebc"').etag == '"6a351fb28bebc"'
+
+
+def test_etag_unquoted_strips_the_quotes():
+ assert FsNode("files/admin/a.txt", etag='"6a351fb28bebc"').etag_unquoted == "6a351fb28bebc"
+
+
+def test_unquoted_etag_passes_through_both_ways():
+ # versions endpoints answer with a bare timestamp instead of a quoted tag
+ node = FsNode("files/admin/a.txt", etag="1785767946")
+ assert node.etag == "1785767946"
+ assert node.etag_unquoted == "1785767946"
+
+
+def test_missing_and_empty_etag_become_empty_string():
+ for node in (FsNode("files/admin/a.txt"), FsNode("files/admin/a.txt", etag=""), FsNode("f/a", etag=None)):
+ assert node.etag == ""
+ assert node.etag_unquoted == ""
+
+
+def test_propfind_record_keeps_the_quoted_etag():
+ assert _parse_record("files/admin/a.txt", [_prop_stat('"6a351fb28bebc"')]).etag == '"6a351fb28bebc"'
+ assert _parse_record("files/admin/a.txt", [_prop_stat('"6a351fb28bebc"')]).etag_unquoted == "6a351fb28bebc"
+ # the trashbin sends ``, which arrives as None
+ assert _parse_record("files/admin/a.txt", [_prop_stat(None)]).etag == ""
+
+
+def test_oc_etag_header_keeps_the_quoted_etag():
+ response = _FakeResponse({"OC-Etag": '"e9673fb8e3e49ff7cbbff9f21e9c60d1"', "OC-FileId": "00000123"})
+ node = FsNode("files/admin/a.txt", **etag_fileid_from_response(response))
+ assert node.etag == '"e9673fb8e3e49ff7cbbff9f21e9c60d1"'
+ assert node.etag_unquoted == "e9673fb8e3e49ff7cbbff9f21e9c60d1"
+
+
+def test_etag_missing_from_headers():
+ response = _FakeResponse({"OC-FileId": "00000123"})
+ assert FsNode("files/admin/a.txt", **etag_fileid_from_response(response)).etag == ""
+
+
+def test_both_sources_agree_for_the_same_file():
+ from_propfind = _parse_record("files/admin/a.txt", [_prop_stat('"e9673fb8e3e49ff7cbbff9f21e9c60d1"')])
+ from_header = FsNode(
+ "files/admin/a.txt",
+ **etag_fileid_from_response(
+ _FakeResponse({"OC-Etag": '"e9673fb8e3e49ff7cbbff9f21e9c60d1"', "OC-FileId": "00000123"})
+ ),
+ )
+ assert from_propfind.etag == from_header.etag
+ assert from_propfind.etag_unquoted == from_header.etag_unquoted == "e9673fb8e3e49ff7cbbff9f21e9c60d1"
+
+
+def test_action_file_info_to_fs_node_keeps_etag():
+ # the ExApp UI file actions build FsNode from data the server posts to the ExApp
+ action_file = ActionFileInfo(
+ fileId=123,
+ name="a.txt",
+ directory="/",
+ etag='"6a351fb28bebc"',
+ mime="text/plain",
+ fileType="file",
+ size=7,
+ favorite="false",
+ permissions=27,
+ mtime=1785767946,
+ userId="admin",
+ )
+ assert action_file.to_fs_node().etag == '"6a351fb28bebc"'
+ assert action_file.to_fs_node().etag_unquoted == "6a351fb28bebc"