Skip to content

Commit 978c295

Browse files
authored
feat: move workspace to platform cache directory (#12) (#29)
* feat: move workspace to platform cache directory (#12) The per-document workspace previously lived in a .docx/<stem>/ folder next to the source file, cluttering user directories and risking collisions between same-named documents. It now lives under the platform user cache, in docx-editor/<hash>/ where <hash> is sha256 of the document's absolute path: Linux $XDG_CACHE_HOME/docx-editor (fallback ~/.cache/docx-editor) macOS ~/Library/Caches/docx-editor Windows %LOCALAPPDATA%\docx-editor (fallback ~/AppData/Local) The base directory can be overridden, in precedence order, by the workspace_dir argument to Document.open()/Workspace(), the DOCX_EDITOR_WORKSPACE_DIR environment variable, or the platform default. A relative override resolves against the source document's directory, so workspace_dir=".docx" reproduces the old next-to-file layout for debugging. Path resolution is centralized in Workspace._resolve_workspace_path(), replacing the duplicated logic in __init__, exists(), and delete(). Base directory cleanup suppresses OSError, since the base is now shared across documents and processes. No auto-migration: workspaces created by older versions are not found, and leftover .docx/ folders can be deleted. * fix: harden workspace cache path resolution and cleanup Follow-up to the cache migration, from a multi-reviewer pass. Crash: the workspace dir was keyed by sha256(str(path).encode()), but str(Path) carries undecodable filename bytes as surrogates, which strict UTF-8 rejects. Opening e.g. "café.docx" (latin-1 on disk) raised UnicodeEncodeError. Hash via os.fsencode(os.path.normcase(path)) instead; this is byte-identical for all decodable paths, so no workspace moves. normcase also folds case on Windows, where one file could otherwise map to two workspaces. Data loss: close()/delete() removed the base directory when it was left empty. That was safe when the base was the library's own .docx/ folder, but the base is now the shared cache root -- or whatever directory the caller passed via workspace_dir=/DOCX_EDITOR_WORKSPACE_DIR. The library was deleting a directory it did not create. It now removes only the document's own workspace folder. Two tests asserted the old behavior and are rewritten. Path resolution: overrides are now tilde-expanded (DOCX_EDITOR_WORKSPACE_DIR =~/ws created a literal "~" directory next to each document), an empty or whitespace override counts as unset and falls through to the next precedence level (workspace_dir="" silently rebased onto the document's own directory), and a relative XDG_CACHE_HOME/%LOCALAPPDATA% is ignored per the XDG spec rather than scattering caches beside every document. Permissions: the workspace holds the document's plaintext in a shared, predictably-named cache, so it and its base are created 0o700 instead of inheriting a 0o775 umask default. mkdir(parents=True, mode=) applies the mode to the leaf only, so the base is created explicitly. Provenance: a workspace whose meta.json names a different source document is now refused instead of silently adopted -- adopting it would let a later save() pack that other document's XML over this one's source file. Errors: filesystem failures at the new cache boundary (unwritable base, no resolvable home) surfaced as raw PermissionError/RuntimeError out of Document.open(). They are now WorkspaceError, naming the override to set. Also: add a public Document.workspace_path -- the docs told users to inspect the preserved workspace but gave them no way to locate it now that it is an opaque hash under the cache. Drop the write-only self.workspace_dir, hoist the function-local shutil import, and correct the "unsaved edits in the workspace" wording (edits reach the workspace only on save()). * fix: correct three regressions in the workspace hardening Found by a second adversarial pass over the previous commit. The permissions test guarded itself with skipif(os.getuid() == 0). A skipif condition is evaluated at collection time on every platform, and os.getuid does not exist on Windows, so the whole test module would die on import there -- taking with it the two tests that exist specifically to cover the Windows cache dir. Guard on os.name first; `or` short-circuits before os.geteuid. The provenance check compared the raw source path while the workspace key is normcase-folded. On a case-insensitive filesystem two spellings map to one workspace directory, which the check would then reject as "a different document" -- and the message tells the user to pass force_recreate=True, which would rmtree the workspace. Fold both sides the same way. Stripping the override with str(override).strip() also stripped Path inputs, silently redirecting a path that legitimately ends in whitespace to a different directory. Strip str overrides (where stray whitespace is config noise) and take a Path verbatim. Also document the WorkspaceError exits that the hardening added to Document.open(), and drop the literal ~ from the workspace_path example -- the value is always expanded.
1 parent d83a4e4 commit 978c295

9 files changed

Lines changed: 542 additions & 75 deletions

File tree

‎docs/api.md‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,23 @@ from docx_editor import Document
1010

1111
### Opening Documents
1212

13-
#### `Document.open(path, author=None, force_recreate=False)`
13+
#### `Document.open(path, author=None, force_recreate=False, workspace_dir=None)`
1414

1515
Open a Word document for editing.
1616

1717
**Parameters:**
1818

1919
- `path` (str | Path): Path to the .docx file
2020
- `author` (str, optional): Author name for tracked changes. Defaults to system username.
21-
- `force_recreate` (bool): If True, delete any existing workspace (stale or in-sync) before opening — any unsaved edits in the workspace are discarded. Use this to recover from `WorkspaceSyncError`. Defaults to False.
21+
- `force_recreate` (bool): If True, delete any existing workspace (stale or in-sync) before opening — whatever XML it holds is discarded — and re-unpack from the current source. Use this to recover from `WorkspaceSyncError`. Defaults to False.
22+
- `workspace_dir` (str | Path, optional): Base directory for the workspace. Overrides the `DOCX_EDITOR_WORKSPACE_DIR` environment variable and the platform cache default (see [Workspace location](#workspace-location)). A relative path resolves against the document's directory, so `workspace_dir=".docx"` keeps the workspace next to the file. Defaults to None.
2223

2324
**Returns:** Document instance ready for editing
2425

2526
**Raises:**
2627

27-
- `WorkspaceSyncError`: If the source `.docx` was modified since the workspace was created. Pass `force_recreate=True` to discard the stale workspace and re-unpack from the current source. The workspace is never deleted silently.
28+
- `WorkspaceSyncError`: If the source `.docx` was modified since the workspace was created. Pass `force_recreate=True` to discard the stale workspace and re-unpack from the current source. The workspace is never deleted silently. The error message includes the workspace path.
29+
- `WorkspaceError`: If the workspace directory cannot be created (e.g. the base is not writable), the home directory backing the default cache cannot be determined, or an existing workspace was unpacked from a different document. The message names the override to set.
2830

2931
**Example:**
3032

@@ -33,6 +35,29 @@ doc = Document.open("contract.docx")
3335
doc = Document.open("contract.docx", author="Legal Team")
3436
```
3537

38+
#### Workspace location
39+
40+
When you open a document, its unpacked OOXML contents are stored in a workspace directory. By default this lives under the platform user cache, in a subfolder `docx-editor/<hash>` where `<hash>` is derived from the document's absolute path:
41+
42+
| Platform | Default base directory |
43+
| --- | --- |
44+
| Linux | `$XDG_CACHE_HOME/docx-editor` (falls back to `~/.cache/docx-editor`) |
45+
| macOS | `~/Library/Caches/docx-editor` |
46+
| Windows | `%LOCALAPPDATA%\docx-editor` (falls back to `~\AppData\Local\docx-editor`) |
47+
48+
To override the location:
49+
50+
- Set the `DOCX_EDITOR_WORKSPACE_DIR` environment variable to a base directory, or
51+
- Pass `workspace_dir=` to `Document.open()` (takes precedence over the environment variable).
52+
53+
Both overrides are tilde-expanded, and an empty value counts as unset. A **relative** override resolves against the document's directory, so `workspace_dir=".docx"` reproduces the old next-to-file layout (handy for debugging). An **absolute** override is used as-is. (A relative `XDG_CACHE_HOME` / `%LOCALAPPDATA%` is ignored, per the XDG spec.)
54+
55+
Cleanup semantics are unchanged: the workspace persists until `close()` is called, and `close(cleanup=False)` preserves it for inspection. `close()` removes only the document's own workspace folder — the base directory is shared and is never deleted, so it is safe to point `workspace_dir` at a directory you also use for other things.
56+
57+
The workspace directory is created with owner-only permissions (`0o700`), since it holds the document's plaintext in a shared cache location. Use `doc.workspace_path` to locate it.
58+
59+
> **Note:** The default location moved from the old `.docx/<stem>/` folder next to the document to the platform cache. Workspaces created by older versions are no longer found and are simply ignored. Delete leftover `.docx/` folders, or pass `workspace_dir=".docx"` to keep using the old layout.
60+
3661
### Properties
3762

3863
#### `author`
@@ -51,6 +76,14 @@ Get the path to the source document.
5176
print(doc.source_path) # Path("/path/to/contract.docx")
5277
```
5378

79+
#### `workspace_path`
80+
81+
Get the path to this document's workspace folder. Since the workspace lives in the user cache by default, this is how you locate the unpacked XML — for example after `close(cleanup=False)`, or when a workspace was preserved because an exception was raised.
82+
83+
```python
84+
print(doc.workspace_path) # Path("/home/you/.cache/docx-editor/0bebafb463a87cfa")
85+
```
86+
5487
### Track Changes Methods
5588

5689
#### `paragraph_count()`

‎docs/quickstart.md‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,26 @@ with Document.open("contract.docx", author="Legal Team") as doc:
2323
doc.save()
2424
```
2525

26-
If the source `.docx` was modified outside this library since the workspace was created, `Document.open()` raises `WorkspaceSyncError` instead of silently discarding the workspace. Pass `force_recreate=True` to acknowledge the divergence and re-unpack from the current source:
26+
If the source `.docx` was modified outside this library since the workspace was created, `Document.open()` raises `WorkspaceSyncError` instead of silently discarding the workspace. The error message includes the workspace path. Pass `force_recreate=True` to acknowledge the divergence and re-unpack from the current source:
2727

2828
```python
2929
doc = Document.open("contract.docx", force_recreate=True)
3030
```
3131

32+
## Workspace location
33+
34+
Opening a document unpacks it into a workspace directory. By default this lives under the platform user cache — `~/.cache/docx-editor/` on Linux (or `$XDG_CACHE_HOME`), `~/Library/Caches/docx-editor/` on macOS, `%LOCALAPPDATA%\docx-editor\` on Windows — in a subfolder named after a hash of the document's absolute path.
35+
36+
Override the base directory with the `DOCX_EDITOR_WORKSPACE_DIR` environment variable, or per-call with `workspace_dir=`:
37+
38+
```python
39+
# Keep the workspace next to the document (relative path → resolved against
40+
# the document's directory); useful for inspecting the unpacked XML.
41+
doc = Document.open("contract.docx", workspace_dir=".docx")
42+
```
43+
44+
The default location moved from the old `.docx/<stem>/` folder next to the document. Leftover `.docx/` folders from older versions are no longer used and can be deleted.
45+
3246
## Paragraph References
3347

3448
Tracked edit methods are scoped to a hash-anchored paragraph reference. Start by listing paragraphs:
@@ -203,7 +217,7 @@ print(f"Rejected {count} revisions")
203217
doc.save() # Save to original path
204218
doc.save("contract_v2.docx") # Save to a new path
205219
doc.close() # Delete workspace
206-
doc.close(cleanup=False) # Keep workspace for inspection
220+
doc.close(cleanup=False) # Keep workspace (in the cache dir) for inspection
207221
```
208222

209223
## Complete Example

‎docx_editor/document.py‎

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,26 +83,43 @@ def open(
8383
path: str | Path,
8484
author: str | None = None,
8585
force_recreate: bool = False,
86+
workspace_dir: str | Path | None = None,
8687
) -> "Document":
8788
"""Open a Word document for editing.
8889
89-
Creates a workspace in .docx/{document_stem}/ alongside the document.
90-
The workspace persists until close() is called.
90+
Creates a workspace holding the document's unpacked contents. By
91+
default the workspace lives under the platform user cache directory
92+
(Linux: ``$XDG_CACHE_HOME`` or ``~/.cache``; macOS:
93+
``~/Library/Caches``; Windows: ``%LOCALAPPDATA%``), in a subfolder
94+
named ``docx-editor/<hash>`` where ``<hash>`` is derived from the
95+
document's absolute path. The workspace persists until close() is
96+
called.
9197
9298
Args:
9399
path: Path to the .docx file
94100
author: Author name for tracked changes (defaults to system username)
95101
force_recreate: If True, delete any existing workspace (stale or
96-
in-sync) before opening. Any unsaved edits in the workspace
97-
are discarded. Use this to recover from WorkspaceSyncError.
102+
in-sync) before opening, discarding whatever XML it holds, and
103+
re-unpack from the current source. Use this to recover from
104+
WorkspaceSyncError.
105+
workspace_dir: Base directory for the workspace. Overrides the
106+
DOCX_EDITOR_WORKSPACE_DIR environment variable and the platform
107+
cache default. Tilde-expanded; an empty value counts as unset.
108+
A relative path resolves against the document's directory, so
109+
``workspace_dir=".docx"`` keeps the workspace next to the file
110+
(handy for debugging).
98111
99112
Returns:
100113
Document instance ready for editing
101114
102115
Raises:
116+
WorkspaceError: If the workspace cannot be created (unwritable base,
117+
undeterminable home directory) or an existing workspace was
118+
unpacked from a different document.
103119
WorkspaceSyncError: If the source document was modified since the
104-
workspace was created. Pass force_recreate=True to discard the
105-
stale workspace and re-unpack from the current source.
120+
workspace was created. The message includes the workspace path.
121+
Pass force_recreate=True to discard the stale workspace and
122+
re-unpack from the current source.
106123
107124
Example:
108125
doc = Document.open("contract.docx")
@@ -111,9 +128,9 @@ def open(
111128
path = Path(path).resolve()
112129

113130
if force_recreate:
114-
Workspace.delete(path)
131+
Workspace.delete(path, workspace_dir=workspace_dir)
115132

116-
workspace = Workspace(path, author=author, create=True)
133+
workspace = Workspace(path, author=author, create=True, workspace_dir=workspace_dir)
117134
return cls(workspace)
118135

119136
@property
@@ -126,6 +143,16 @@ def source_path(self) -> Path:
126143
"""Get the path to the source document."""
127144
return self._workspace.source_path
128145

146+
@property
147+
def workspace_path(self) -> Path:
148+
"""Get the path to this document's workspace folder.
149+
150+
The workspace lives under the user cache by default, so this is the
151+
only way to locate the unpacked XML — e.g. after close(cleanup=False)
152+
or when a workspace is preserved because an exception was raised.
153+
"""
154+
return self._workspace.workspace_path
155+
129156
# ==================== Track Changes API ====================
130157

131158
def find_text(self, text: str, occurrence: int = 0):

0 commit comments

Comments
 (0)