calculate_digest()

Calculates locally the same digest enaio stores for a document with exactly these files — without contacting the server and without uploading the files.

This answers "is this file already archived?" before an upload: calculate the value and look it up with objects_by_digest(), or compare it directly against table_digest from digest(). (Ticket #16111)

1. How the digest is built

enaio does not hash the concatenated file contents. It hashes the digests of the individual files:

  1. SHA256 over every single file.

  2. Each of those digests is rendered as an UPPERCASE hex string and concatenated, in file order, into one ASCII string.

  3. SHA256 over the ASCII bytes of that string.

SHA256( "".join(SHA256(file).hexdigest().upper() for file in files).encode("ascii") )
A document with a single file is therefore not the plain SHA256 of that file — the hex string goes through the second SHA256 round as well.

The file order is part of the digest. It must match the order in which the files were passed to insert().

The returned value is uppercase, the way enaio stores and returns it, so it compares equal to ECMDocumentDigest.table_digest without any normalisation.

2. Signature

  • Sync

  • Async

ecm.dms.calculate_digest(
    files: ECMDigestSource | Iterable[ECMDigestSource],
    *,
    chunk_size: int = 65536,
) -> str
await ecm.dms.calculate_digest(
    files: ECMDigestSource | Iterable[ECMDigestSource],
    *,
    chunk_size: int = 65536,
) -> str
The async variant sends no job either. It is a coroutine so that it fits the async call style and — more importantly — so that reading and hashing run in a worker thread via asyncio.to_thread instead of blocking the event loop on a large file.

3. Parameters

Parameter Type Default Description

files

ECMDigestSource | Iterable[ECMDigestSource]

—

One file source or an iterable of them, in the order the files belong to the document. A single source needs no list around it.

chunk_size

int

65536

Number of bytes read per step for streamed sources. Affects memory use only, never the result.

3.1. Accepted file sources

Type Meaning

bytes / bytearray / memoryview

The file content.

str / os.PathLike

A path the content is read from. A str is always a path, never content — pass text.encode() for that.

JobRequestFile

The same object handed to insert() or checkin(). A JobRequestFileFromReader is consumed by the calculation and cannot be uploaded afterwards.

JobResponseFile

As returned by files().

Open binary stream

Read from its current position to the end.

4. Return value

The document digest as a 64-character uppercase hex string.

5. Exceptions

Exception Condition

ValueError

files is empty, or chunk_size is not positive.

TypeError

files or one of its entries is not a supported source.

FileNotFoundError

An entry passed as a path does not exist.

6. Examples

6.1. Check whether a file is already archived

  • Sync

  • Async

local = ecm.dms.calculate_digest("invoice.pdf")
if ecm.dms.objects_by_digest(local):
    print("already archived")
else:
    ecm.dms.insert(InvoiceDocument(Title="Invoice"), files=[RequestFileFromPath("invoice.pdf")])
local = await ecm.dms.calculate_digest("invoice.pdf")
if await ecm.dms.objects_by_digest(local):
    print("already archived")
else:
    await ecm.dms.insert(InvoiceDocument(Title="Invoice"), files=[RequestFileFromPath("invoice.pdf")])

6.2. Compare against a known document

  • Sync

  • Async

if ecm.dms.calculate_digest("invoice.pdf") == ecm.dms.digest(doc).table_digest:
    print("identical to the archived document")
if await ecm.dms.calculate_digest("invoice.pdf") == (await ecm.dms.digest(doc)).table_digest:
    print("identical to the archived document")

6.3. Multiple files — the order matters

# The order must match the order used for insert()
digest = ecm.dms.calculate_digest(["page1.png", "page2.png"])

6.4. Straight from the files about to be inserted

from ecmind_blue_client.rpc import JobRequestFileFromPath

files = [JobRequestFileFromPath("page1.png"), JobRequestFileFromPath("page2.png")]
if not ecm.dms.objects_by_digest(ecm.dms.calculate_digest(files)):
    ecm.dms.insert(MyDocument(Name="Scan"), files=files)

6.5. Re-check downloaded files

# Integrity check without asking the server to recalculate
downloaded = ecm.dms.files(doc)
assert ecm.dms.calculate_digest(downloaded) == ecm.dms.digest(doc).table_digest

7. Notes

  • Files are read in chunks. A multi-gigabyte document costs one chunk of memory, not its size.

  • The server-side lookup is case insensitive, so a digest from a foreign system needs no case conversion before it is looked up.

  • Verified against a 12.0 server for documents with one, two and three files.

8. See also