Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/did/did_matlab_python_bridge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -939,7 +939,7 @@ classes:
- name: document
type: class
matlab_path: "+did/document.m"
matlab_last_sync_hash: "c95b4f2"
matlab_last_sync_hash: "bb29861"
status: ported
python_path: "did/document.py"
python_class: "Document"
Expand Down Expand Up @@ -1414,6 +1414,17 @@ classes:
Both languages now accept NAME_<i> as a numbered entry only when <i>
is a non-negative integer.

Sync hash bumped to bb29861 on 2026-10-23. That commit replaced the
constructor's inline char(datetime('now','TimeZone','UTCLeapSeconds'))
with did.fun.timestamp(), which clamps a ':60' leap second to
':59.999' (DID-matlab issue #53). Ported the same way: Document.__init__
now stamps base.datestamp with did.fun.timestamp() (see the timestamp
entry in did_matlab_python_bridge_util.yaml). The module-private
document._utc_timestamp it replaces had its own clamp, a plain
':60.' -> ':59.999' substitution that would have turned ':60.123' into
':59.999123'; it is removed rather than kept beside the port.
Coverage: tests/test_timestamp.py.

# -----------------------------------------------------------------------
# did.query
# -----------------------------------------------------------------------
Expand Down
64 changes: 63 additions & 1 deletion src/did/did_matlab_python_bridge_implementations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ classes:
- name: sqlitedb
type: class
matlab_path: "+did/+implementations/sqlitedb.m"
matlab_last_sync_hash: "8bea92c"
matlab_last_sync_hash: "da83fa3"
status: ported
python_path: "did/implementations/sqlitedb.py"
python_class: "SQLiteDB"
Expand Down Expand Up @@ -133,6 +133,60 @@ classes:
new tests, including the acceptance case, the "one fetch shared
across members" cache guarantee, and the handler-refused failure).

2026-10-23: synced through DID-matlab decd155 and da83fa3 (PRs #206,
#207), the slow-drive SQLITE_READONLY work. MATLAB (1) sets
busy_timeout=30000 in open_db, (2) retries a BUSY / READONLY failure in
run_sql_noOpen with backoffs 0.1, 0.2, 0.5, 1, 2, 4 s, (3) closes and
reopens the connection before each READONLY retry (a poisoned handle,
SQLITE_READONLY_DBMOVED, which waiting never clears), and (4) prints
filesystem diagnostics to stderr when the retries are exhausted.
Ported: _open_db passes timeout=30.0 to sqlite3.connect (the same
busy_timeout); do_run_sql_query retries on the same schedule, reopening
first for READONLY; _is_retryable_sql_error, _is_readonly_error and
_collect_readonly_diagnostics mirror the three new MATLAB helpers; the
debug-gated progress messages are printed when self.debug is set.
The three helpers are protected in MATLAB and so carry no member
entries here. _is_retryable_sql_error and _is_readonly_error are
staticmethods (MATLAB's take ~ for the object). The diagnostics run the
same four probes -- db file and time, -journal/-wal/-shm siblings with
sizes, the file's write permission (os.access for fileattrib's
UserWrite), and a temp-file write probe of the directory -- but print
the time as ISO-8601 UTC rather than MATLAB's local datestr.

DEVIATION -- where the retry may run. mksqlite autocommits every
statement, so in MATLAB a failed statement is the whole unit of work
and re-running it (even after a reopen) is safe. SQLiteDB groups each
add/remove/branch operation into one sqlite3 transaction. Rolling back
or reopening mid-transaction would silently discard the statements
already pending, and a retried tail would then commit a partial
operation. So do_run_sql_query retries only when the connection had no
uncommitted work before the statement; inside a transaction the error
is reported (with diagnostics) and raised at once, and the caller's
existing rollback runs.

DEVIATION -- the commit is retried too. Because Python's writes reach
the disk at COMMIT rather than at each statement, that is where a
slow-drive failure lands here. Every commit now goes through
_commit(), which retries a BUSY / READONLY COMMIT on the same schedule
while the transaction is still open. It never reopens -- that would
lose the transaction -- so a genuinely poisoned handle (DBMOVED) is not
recovered at COMMIT: the error is raised and the operation rolled back.
Replaying a whole operation after a reopen would cover that case, but
_do_add_doc also copies ingested files, so it is not obviously
idempotent; not attempted.

DEVIATION -- classification. Like MATLAB, BUSY vs READONLY is judged
from the error message (sqlite3.Error.sqlite_errorcode would be exact
but is 3.11+ only, and this package supports 3.10). Unlike MATLAB, only
sqlite3.Error instances qualify, so an unrelated Python exception whose
text says "read only" is never retried.

Not ported: MATLAB's run_sql_noOpen also prints every failing query
and its bound values to stderr. That predates these commits, and
Python never did it; only the new diagnostics block is printed here.
Coverage: tests/test_sqlitedb_transient_errors.py (real lock and
read-only-handle failures, with time.sleep replaced).

properties:
- name: FileDir
python_name: ~ # not ported
Expand Down Expand Up @@ -174,6 +228,10 @@ classes:
Python: do_run_sql_query(query_str, params=()).
Python uses parameterized queries. Synchronized 2026-03-15.

Carries MATLAB run_sql_noOpen's BUSY / READONLY retry (decd155,
da83fa3), restricted to statements run with no transaction open;
see the entry-level sync_notes, 2026-10-23.

- name: do_add_doc
python_name: _do_add_doc
out_of_sync: false
Expand Down Expand Up @@ -571,6 +629,10 @@ classes:
combined "open_db / close_db" entry 2026-08-28 so each method
resolves on its own.

Both set a 30 s busy timeout (decd155): MATLAB with pragma
busy_timeout=30000, Python with sqlite3.connect(timeout=30.0),
which is the same setting. Python's previous default was 5 s.

- name: close_db
python_name: _close_db
decision_log: >
Expand Down
35 changes: 35 additions & 0 deletions src/did/did_matlab_python_bridge_util.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,41 @@ functions:
Both provide interactive document graph visualization.
Synchronized 2026-03-15.

# -----------------------------------------------------------------------
# did.fun.timestamp
# -----------------------------------------------------------------------
- name: timestamp
type: function
matlab_path: "+did/+fun/timestamp.m"
matlab_last_sync_hash: "bb29861"
status: ported
python_path: "did/fun.py"
python_name: "timestamp"

input_arguments:
- name: raw
type_matlab: "(1,:) char, default char(datetime('now','TimeZone','UTCLeapSeconds'))"
type_python: "str | None, default None (the current UTC time)"
output_arguments:
- name: ts
type_matlab: "char"
type_python: "str"

decision_log: >
Ported 2026-10-23 from DID-matlab bb29861 (issue #53). Same regex,
(T\d{2}:\d{2}:)60(\.\d+)? -> \g<1>59.999, so a leap second with or
without a fraction and with or without a trailing 'Z' clamps exactly as
in MATLAB, and nothing outside the seconds field is touched. The
default value is the current UTC time as 'yyyy-mm-ddTHH:MM:SS.fffZ',
the shape MATLAB's UTCLeapSeconds char renders. One difference, of
fact rather than of design: Python's datetime cannot represent a leap
second, so the default path never produces ':60' and the clamp only
ever acts on a supplied raw string (e.g. one written by MATLAB).
Python's validator (validate._parses_as_timestamp, via
datetime.fromisoformat) rejects ':60' as java.time.LocalDateTime.parse
does, so the clamp is needed on that path for the same reason.
Coverage: tests/test_timestamp.py, mirroring MATLAB's TestTimestamp.

# =========================================================================
# did.datastructures — Data structure utilities
# =========================================================================
Expand Down
21 changes: 2 additions & 19 deletions src/did/document.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,9 @@
import contextlib
import os
from datetime import datetime, timezone

from . import ido
from .common import PathConstants


def _utc_timestamp():
"""Return the current UTC time as an ISO-8601 millisecond string with 'Z'.

Ported from NDI-python (ndi.fun.timestamp) with the leap-second guard.
``str(datetime.utcnow())`` previously emitted a space-separated,
timezone-less string ('2026-07-20 22:36:19.611068') that diverges from the
DID-matlab UTCLeapSeconds ISO-8601 output and from base.schema.json's own
default ('2018-12-05T18:36:47.241Z'); a JS ``new Date()`` parses the
tz-less form as LOCAL time. Emit '%Y-%m-%dT%H:%M:%S.%fZ' truncated to
milliseconds, clamping the (theoretical) leap-second :60 to :59.999.
"""
now = datetime.now(timezone.utc)
ts = now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
ts = ts.replace(":60.", ":59.999")
return ts + "Z"
from .fun import timestamp


class Document:
Expand All @@ -30,7 +13,7 @@ def __init__(self, document_type="base", **kwargs):
else:
self.document_properties = self.read_blank_definition(document_type)
self.document_properties["base"]["id"] = ido.IDO.unique_id()
self.document_properties["base"]["datestamp"] = _utc_timestamp()
self.document_properties["base"]["datestamp"] = timestamp()

for key, value in kwargs.items():
path = key.split(".")
Expand Down
31 changes: 31 additions & 0 deletions src/did/fun.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
import re
from datetime import datetime, timezone

import networkx as nx

# A leap-second seconds field (':60' with an optional fraction), kept with its
# 'T HH:MM:' prefix so the date portion is never touched.
_LEAP_SECOND = re.compile(r"(T\d{2}:\d{2}:)60(\.\d+)?")


def timestamp(raw=None):
"""
A current UTC timestamp string that is safe for DID schema validation.

Returns the current time in the ISO-8601 form used throughout DID,
``'2026-09-18T21:37:45.123Z'``. ``timestamp(raw)`` sanitizes the given
string instead of the current time, which is mainly useful for testing.

A leap second (``':60'``, with or without a fraction) is clamped back to
``':59.999'``: the schema validator parses timestamps the way MATLAB's
``java.time.LocalDateTime.parse`` does, which rejects any seconds value
above 59. Python's ``datetime`` never renders a leap second itself, so on
the default path the clamp is a guard rather than a fix; it matters for
strings that came from MATLAB's ``UTCLeapSeconds`` clock.

This function mimics the behavior of the Matlab `did.fun.timestamp`
function (DID-matlab issue #53).
"""
if raw is None:
now = datetime.now(timezone.utc)
raw = now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
return _LEAP_SECOND.sub(r"\g<1>59.999", raw)


def docs_to_graph(document_objs):
"""
Expand Down
Loading
Loading