dimsechord 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dimsechord-0.3.0/.claude/hooks/block-branch-switch.sh +40 -0
- dimsechord-0.3.0/.claude/hooks/pr-monitor.sh +32 -0
- dimsechord-0.3.0/.claude/hooks/pr-watch.sh +58 -0
- dimsechord-0.3.0/.claude/hooks/require-worktree-agent.sh +33 -0
- dimsechord-0.3.0/.claude/hooks/require-worktree.sh +33 -0
- dimsechord-0.3.0/.claude/hooks/worktree-stop.sh +53 -0
- dimsechord-0.3.0/.claude/settings.json +60 -0
- dimsechord-0.3.0/.github/workflows/release.yml +52 -0
- dimsechord-0.3.0/.gitignore +15 -0
- dimsechord-0.3.0/CLAUDE.md +13 -0
- dimsechord-0.3.0/LICENSE +21 -0
- dimsechord-0.3.0/PKG-INFO +122 -0
- dimsechord-0.3.0/README.md +95 -0
- dimsechord-0.3.0/docs/cookbook.md +227 -0
- dimsechord-0.3.0/docs/tutorial-gateway.md +151 -0
- dimsechord-0.3.0/docs/typing.md +86 -0
- dimsechord-0.3.0/docs/why.md +95 -0
- dimsechord-0.3.0/pyproject.toml +78 -0
- dimsechord-0.3.0/src/dimsechord/__init__.py +81 -0
- dimsechord-0.3.0/src/dimsechord/_cache.py +242 -0
- dimsechord-0.3.0/src/dimsechord/_client.py +248 -0
- dimsechord-0.3.0/src/dimsechord/_converter.py +185 -0
- dimsechord-0.3.0/src/dimsechord/_exceptions.py +25 -0
- dimsechord-0.3.0/src/dimsechord/_handlers.py +176 -0
- dimsechord-0.3.0/src/dimsechord/_index.py +167 -0
- dimsechord-0.3.0/src/dimsechord/_models.py +189 -0
- dimsechord-0.3.0/src/dimsechord/_multipart.py +50 -0
- dimsechord-0.3.0/src/dimsechord/_pool.py +82 -0
- dimsechord-0.3.0/src/dimsechord/_pull_engine.py +392 -0
- dimsechord-0.3.0/src/dimsechord/_scp.py +177 -0
- dimsechord-0.3.0/src/dimsechord/_scu.py +838 -0
- dimsechord-0.3.0/src/dimsechord/py.typed +0 -0
- dimsechord-0.3.0/tests/__init__.py +0 -0
- dimsechord-0.3.0/tests/conftest.py +56 -0
- dimsechord-0.3.0/tests/factories.py +49 -0
- dimsechord-0.3.0/tests/fake_pacs.py +167 -0
- dimsechord-0.3.0/tests/integration/__init__.py +0 -0
- dimsechord-0.3.0/tests/integration/test_client_get.py +36 -0
- dimsechord-0.3.0/tests/integration/test_end_to_end.py +57 -0
- dimsechord-0.3.0/tests/integration/test_pull_engine.py +113 -0
- dimsechord-0.3.0/tests/integration/test_pull_engine_cget.py +106 -0
- dimsechord-0.3.0/tests/integration/test_scp_streaming.py +61 -0
- dimsechord-0.3.0/tests/integration/test_scu_find.py +77 -0
- dimsechord-0.3.0/tests/integration/test_scu_get.py +60 -0
- dimsechord-0.3.0/tests/integration/test_scu_move.py +114 -0
- dimsechord-0.3.0/tests/unit/__init__.py +0 -0
- dimsechord-0.3.0/tests/unit/test_cache.py +78 -0
- dimsechord-0.3.0/tests/unit/test_converter.py +63 -0
- dimsechord-0.3.0/tests/unit/test_exceptions.py +23 -0
- dimsechord-0.3.0/tests/unit/test_handlers_forward.py +59 -0
- dimsechord-0.3.0/tests/unit/test_index.py +80 -0
- dimsechord-0.3.0/tests/unit/test_models.py +74 -0
- dimsechord-0.3.0/tests/unit/test_multipart.py +29 -0
- dimsechord-0.3.0/tests/unit/test_pool.py +69 -0
- dimsechord-0.3.0/tests/unit/test_public_api.py +131 -0
- dimsechord-0.3.0/uv.lock +354 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook for Bash: blocks branch switching in the root project directory.
|
|
3
|
+
# Use EnterWorktree to work on a different branch.
|
|
4
|
+
|
|
5
|
+
INPUT=$(cat)
|
|
6
|
+
if command -v jq >/dev/null 2>&1; then
|
|
7
|
+
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')
|
|
8
|
+
else
|
|
9
|
+
COMMAND=$(printf '%s' "$INPUT" | grep -oP '"command"\s*:\s*"\K[^"]*' || true)
|
|
10
|
+
fi
|
|
11
|
+
[ -z "$COMMAND" ] && exit 0
|
|
12
|
+
|
|
13
|
+
# Check for git checkout/switch (not file-restore variant)
|
|
14
|
+
if ! echo "$COMMAND" | grep -qP 'git\s+(checkout|switch)\b'; then
|
|
15
|
+
exit 0
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
# Allow file restore: `git checkout [<rev>] -- <path>` / `git switch ... -- <path>`.
|
|
19
|
+
# The `--` (and any preceding revision) must belong to this checkout/switch, not a
|
|
20
|
+
# later chained command, so the match cannot cross a ;, & or | separator.
|
|
21
|
+
if echo "$COMMAND" | grep -qP 'git\s+(checkout|switch)\b[^;&|]*\s--(\s|$)'; then
|
|
22
|
+
exit 0
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
26
|
+
|
|
27
|
+
# Check that we're in the root directory (not a worktree)
|
|
28
|
+
GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
|
|
29
|
+
COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null)
|
|
30
|
+
|
|
31
|
+
if [ "$(realpath "$GIT_DIR")" = "$(realpath "$COMMON_DIR")" ]; then
|
|
32
|
+
cat >&2 <<'EOF'
|
|
33
|
+
BLOCKED: Branch switching in the root project directory is not allowed.
|
|
34
|
+
Use EnterWorktree to work on a different branch.
|
|
35
|
+
`git checkout -- <file>` for restoring files is still available.
|
|
36
|
+
EOF
|
|
37
|
+
exit 2
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
exit 0
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PostToolUse hook: detect `gh pr create`, start background monitoring.
|
|
3
|
+
|
|
4
|
+
INPUT=$(cat)
|
|
5
|
+
|
|
6
|
+
echo "$INPUT" | grep -q "gh pr create" || exit 0
|
|
7
|
+
echo "$INPUT" | grep -qE "(--help|--dry-run)" && exit 0
|
|
8
|
+
|
|
9
|
+
PR_URL=$(echo "$INPUT" | grep -oP 'https://github\.com/[^"]+/pull/\d+' | head -1)
|
|
10
|
+
[ -z "$PR_URL" ] && exit 0
|
|
11
|
+
|
|
12
|
+
PR_NUM=$(echo "$PR_URL" | grep -oP '\d+$')
|
|
13
|
+
REPORT="/tmp/pr-${PR_NUM}-report.md"
|
|
14
|
+
HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
15
|
+
|
|
16
|
+
# Fully detach the watcher into its own session so it survives the hook's
|
|
17
|
+
# process-group teardown (nohup alone only ignores SIGHUP, not SIGTERM/SIGKILL).
|
|
18
|
+
if command -v setsid >/dev/null 2>&1; then
|
|
19
|
+
setsid "$HOOK_DIR/pr-watch.sh" "$PR_NUM" 12 300 </dev/null >/dev/null 2>&1 &
|
|
20
|
+
else
|
|
21
|
+
nohup "$HOOK_DIR/pr-watch.sh" "$PR_NUM" 12 300 </dev/null >/dev/null 2>&1 &
|
|
22
|
+
disown
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
cat >&2 <<EOF
|
|
26
|
+
PR_CREATED: PR #${PR_NUM} (${PR_URL}).
|
|
27
|
+
Background CI monitor started (PID $!, polling every 5 min, max 1 hour).
|
|
28
|
+
Report: ${REPORT}
|
|
29
|
+
When the user asks about PR status, read ${REPORT}.
|
|
30
|
+
EOF
|
|
31
|
+
|
|
32
|
+
exit 2
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Background PR monitor — polls GitHub CI checks and writes report.
|
|
3
|
+
# Usage: pr-watch.sh <PR_NUMBER> [max_iterations] [interval_seconds]
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
PR_NUM="${1:?Usage: pr-watch.sh <PR_NUMBER>}"
|
|
8
|
+
MAX_ITER="${2:-12}"
|
|
9
|
+
INTERVAL="${3:-300}"
|
|
10
|
+
REPORT="/tmp/pr-${PR_NUM}-report.md"
|
|
11
|
+
|
|
12
|
+
echo "# PR #${PR_NUM} — Monitoring started $(date -Iseconds)" > "$REPORT"
|
|
13
|
+
echo "Checking every ${INTERVAL}s, max ${MAX_ITER} iterations." >> "$REPORT"
|
|
14
|
+
|
|
15
|
+
for ((i=1; i<=MAX_ITER; i++)); do
|
|
16
|
+
# Poll before sleeping so an already-finished CI is reported immediately.
|
|
17
|
+
# gh pr checks exit codes: 0=all pass, 1=some failed, 8=pending, other=error.
|
|
18
|
+
if CHECKS=$(gh pr checks "$PR_NUM" 2>&1); then RC=0; else RC=$?; fi
|
|
19
|
+
STATUS_JSON=$(gh pr view "$PR_NUM" --json statusCheckRollup,comments,reviews,state 2>&1) || true
|
|
20
|
+
|
|
21
|
+
# Only treat an authoritative pass(0)/fail(1) as terminal; pending(8) and any
|
|
22
|
+
# gh error keep us waiting instead of writing a false "complete" report.
|
|
23
|
+
if [ "$RC" -eq 0 ] || [ "$RC" -eq 1 ]; then
|
|
24
|
+
cat > "$REPORT" <<REPORT_EOF
|
|
25
|
+
# PR #${PR_NUM} — CI Complete ($(date -Iseconds))
|
|
26
|
+
|
|
27
|
+
## Check Results
|
|
28
|
+
\`\`\`
|
|
29
|
+
${CHECKS}
|
|
30
|
+
\`\`\`
|
|
31
|
+
|
|
32
|
+
## Reviews & Comments
|
|
33
|
+
\`\`\`json
|
|
34
|
+
${STATUS_JSON}
|
|
35
|
+
\`\`\`
|
|
36
|
+
REPORT_EOF
|
|
37
|
+
exit 0
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
echo "Iteration ${i}/${MAX_ITER}: checks not final (gh rc=${RC}, $(date -Iseconds))" >> "$REPORT"
|
|
41
|
+
sleep "$INTERVAL"
|
|
42
|
+
done
|
|
43
|
+
|
|
44
|
+
cat > "$REPORT" <<REPORT_EOF
|
|
45
|
+
# PR #${PR_NUM} — Monitoring Timeout ($(date -Iseconds))
|
|
46
|
+
|
|
47
|
+
Gave up after ${MAX_ITER} iterations ($(( MAX_ITER * INTERVAL / 60 )) minutes).
|
|
48
|
+
|
|
49
|
+
## Last Check Results
|
|
50
|
+
\`\`\`
|
|
51
|
+
${CHECKS:-no data}
|
|
52
|
+
\`\`\`
|
|
53
|
+
|
|
54
|
+
## Reviews & Comments
|
|
55
|
+
\`\`\`json
|
|
56
|
+
${STATUS_JSON:-no data}
|
|
57
|
+
\`\`\`
|
|
58
|
+
REPORT_EOF
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook for Agent: only read-only agents may run on the main branch.
|
|
3
|
+
# Any agent that can mutate files must run inside a worktree, so analysis and
|
|
4
|
+
# edits happen in the same context.
|
|
5
|
+
|
|
6
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
7
|
+
|
|
8
|
+
BRANCH=$(git branch --show-current 2>/dev/null)
|
|
9
|
+
[ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ] && exit 0
|
|
10
|
+
|
|
11
|
+
INPUT=$(cat)
|
|
12
|
+
if command -v jq >/dev/null 2>&1; then
|
|
13
|
+
SUBAGENT=$(printf '%s' "$INPUT" | jq -r '.tool_input.subagent_type // empty')
|
|
14
|
+
else
|
|
15
|
+
SUBAGENT=$(printf '%s' "$INPUT" | grep -oP '"subagent_type"\s*:\s*"\K[^"]*' || true)
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
# Allowlist: read-only agents (no Edit/Write/NotebookEdit) may run on main.
|
|
19
|
+
# Everything else — including write-capable general-purpose agents — is blocked,
|
|
20
|
+
# so a write-capable agent can never run on main by default (fail-closed).
|
|
21
|
+
case "$SUBAGENT" in
|
|
22
|
+
Explore|Plan|feature-dev:code-explorer|feature-dev:code-reviewer|claude-code-guide)
|
|
23
|
+
exit 0
|
|
24
|
+
;;
|
|
25
|
+
esac
|
|
26
|
+
|
|
27
|
+
cat >&2 <<'EOF'
|
|
28
|
+
BLOCKED: This agent can modify files and may not run on the main branch.
|
|
29
|
+
Use EnterWorktree before launching development or architecture agents.
|
|
30
|
+
This ensures analysis and subsequent changes happen in the same worktree.
|
|
31
|
+
(Read-only agents such as Explore/Plan/code-explorer/code-reviewer are allowed.)
|
|
32
|
+
EOF
|
|
33
|
+
exit 2
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook: blocks Edit/Write on the main branch.
|
|
3
|
+
# All changes require a worktree or feature branch.
|
|
4
|
+
|
|
5
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
6
|
+
|
|
7
|
+
# Skip files outside the repository (plan files, global .claude/, etc.)
|
|
8
|
+
INPUT=$(cat)
|
|
9
|
+
if command -v jq >/dev/null 2>&1; then
|
|
10
|
+
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty')
|
|
11
|
+
else
|
|
12
|
+
FILE_PATH=$(printf '%s' "$INPUT" | grep -oP '"file_path"\s*:\s*"\K[^"]*' || true)
|
|
13
|
+
fi
|
|
14
|
+
if [ -n "$FILE_PATH" ]; then
|
|
15
|
+
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
16
|
+
case "$FILE_PATH" in
|
|
17
|
+
"$REPO_ROOT"/.claude/*) exit 0 ;; # infrastructure files — always allow
|
|
18
|
+
"$REPO_ROOT"/*) ;; # file in repo — check further
|
|
19
|
+
*) exit 0 ;; # file outside repo — skip
|
|
20
|
+
esac
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
BRANCH=$(git branch --show-current 2>/dev/null)
|
|
24
|
+
|
|
25
|
+
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "master" ]; then
|
|
26
|
+
cat >&2 <<'EOF'
|
|
27
|
+
BLOCKED: Editing files on the main branch is not allowed.
|
|
28
|
+
Use EnterWorktree before making changes.
|
|
29
|
+
EOF
|
|
30
|
+
exit 2
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
exit 0
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Stop hook: blocks session end in a worktree,
|
|
3
|
+
# so Claude asks the user what to do with changes.
|
|
4
|
+
|
|
5
|
+
INPUT=$(cat)
|
|
6
|
+
|
|
7
|
+
# Prevent infinite loop — skip on retry
|
|
8
|
+
if echo "$INPUT" | grep -qE '"stop_hook_active"\s*:\s*true'; then
|
|
9
|
+
exit 0
|
|
10
|
+
fi
|
|
11
|
+
|
|
12
|
+
# Debounce per session: don't block again within 120 seconds.
|
|
13
|
+
# Key on the session id (stable, collision-free) rather than a reusable PPID.
|
|
14
|
+
if command -v jq >/dev/null 2>&1; then
|
|
15
|
+
SESSION_ID=$(printf '%s' "$INPUT" | jq -r '.session_id // empty')
|
|
16
|
+
fi
|
|
17
|
+
[ -z "${SESSION_ID:-}" ] && SESSION_ID="$PPID"
|
|
18
|
+
DEBOUNCE_FILE="/tmp/claude-worktree-stop-${SESSION_ID}"
|
|
19
|
+
if [ -f "$DEBOUNCE_FILE" ]; then
|
|
20
|
+
LAST=$(cat "$DEBOUNCE_FILE" 2>/dev/null || echo 0)
|
|
21
|
+
NOW=$(date +%s)
|
|
22
|
+
if [ $((NOW - LAST)) -lt 120 ]; then
|
|
23
|
+
exit 0
|
|
24
|
+
fi
|
|
25
|
+
fi
|
|
26
|
+
|
|
27
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
28
|
+
|
|
29
|
+
# Check that this is a worktree (not the main repo)
|
|
30
|
+
GIT_DIR=$(git rev-parse --git-dir 2>/dev/null)
|
|
31
|
+
GIT_COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null)
|
|
32
|
+
if [ "$GIT_DIR" = "$GIT_COMMON_DIR" ]; then
|
|
33
|
+
exit 0
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
BRANCH=$(git branch --show-current 2>/dev/null)
|
|
37
|
+
HAS_CHANGES=$(git status --porcelain 2>/dev/null | head -1)
|
|
38
|
+
AHEAD=$(git rev-list main..HEAD --count 2>/dev/null || git rev-list master..HEAD --count 2>/dev/null || echo "0")
|
|
39
|
+
|
|
40
|
+
date +%s > "$DEBOUNCE_FILE"
|
|
41
|
+
|
|
42
|
+
cat >&2 <<EOF
|
|
43
|
+
WORKTREE_PENDING: Session is in a worktree, branch '$BRANCH'.
|
|
44
|
+
Commits ahead of main: $AHEAD
|
|
45
|
+
Uncommitted changes: $([ -n "$HAS_CHANGES" ] && echo "yes" || echo "no")
|
|
46
|
+
|
|
47
|
+
Before ending, ask the user:
|
|
48
|
+
1) Push branch + create PR to main + ExitWorktree(keep)
|
|
49
|
+
2) Keep worktree (to continue later) — ExitWorktree(keep)
|
|
50
|
+
3) Discard changes + remove worktree — ExitWorktree(remove, discard_changes=true)
|
|
51
|
+
EOF
|
|
52
|
+
|
|
53
|
+
exit 2
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PreToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "Edit|Write",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/require-worktree.sh",
|
|
10
|
+
"timeout": 5
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"matcher": "Agent",
|
|
16
|
+
"hooks": [
|
|
17
|
+
{
|
|
18
|
+
"type": "command",
|
|
19
|
+
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/require-worktree-agent.sh",
|
|
20
|
+
"timeout": 5
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"matcher": "Bash",
|
|
26
|
+
"hooks": [
|
|
27
|
+
{
|
|
28
|
+
"type": "command",
|
|
29
|
+
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block-branch-switch.sh",
|
|
30
|
+
"timeout": 5
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
"PostToolUse": [
|
|
36
|
+
{
|
|
37
|
+
"matcher": "Bash",
|
|
38
|
+
"hooks": [
|
|
39
|
+
{
|
|
40
|
+
"type": "command",
|
|
41
|
+
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pr-monitor.sh",
|
|
42
|
+
"timeout": 5
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"Stop": [
|
|
48
|
+
{
|
|
49
|
+
"matcher": "",
|
|
50
|
+
"hooks": [
|
|
51
|
+
{
|
|
52
|
+
"type": "command",
|
|
53
|
+
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-stop.sh",
|
|
54
|
+
"timeout": 10
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions: {}
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
name: Build distributions
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- name: Install uv
|
|
19
|
+
uses: astral-sh/setup-uv@v5
|
|
20
|
+
- name: Verify tag matches package version
|
|
21
|
+
run: |
|
|
22
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
23
|
+
pkg="$(grep -m1 '^version = ' pyproject.toml | sed -E 's/version = "(.*)"/\1/')"
|
|
24
|
+
if [ "$tag" != "$pkg" ]; then
|
|
25
|
+
echo "::error::Tag v$tag does not match pyproject version $pkg" >&2
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
- name: Build sdist and wheel
|
|
29
|
+
run: uv build
|
|
30
|
+
- name: Check metadata
|
|
31
|
+
run: uvx twine check dist/*
|
|
32
|
+
- uses: actions/upload-artifact@v4
|
|
33
|
+
with:
|
|
34
|
+
name: dist
|
|
35
|
+
path: dist/
|
|
36
|
+
|
|
37
|
+
publish:
|
|
38
|
+
name: Publish to PyPI
|
|
39
|
+
needs: build
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
environment:
|
|
42
|
+
name: pypi
|
|
43
|
+
url: https://pypi.org/p/dimsechord
|
|
44
|
+
permissions:
|
|
45
|
+
id-token: write
|
|
46
|
+
steps:
|
|
47
|
+
- uses: actions/download-artifact@v4
|
|
48
|
+
with:
|
|
49
|
+
name: dist
|
|
50
|
+
path: dist/
|
|
51
|
+
- name: Publish via Trusted Publishing
|
|
52
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
.mypy_cache/
|
|
6
|
+
.ruff_cache/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
|
|
11
|
+
# AI planning artifacts (kept locally, not published)
|
|
12
|
+
docs/superpowers/
|
|
13
|
+
|
|
14
|
+
# Claude Code local settings
|
|
15
|
+
.claude/settings.local.json
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# dimsechord
|
|
2
|
+
|
|
3
|
+
## Worktree Workflow
|
|
4
|
+
|
|
5
|
+
- Feature development: always enter a worktree via `EnterWorktree` before making changes
|
|
6
|
+
- Any file change needs a worktree — the `require-worktree` hook blocks Edit/Write on `main`; only `.claude/` infrastructure files are exempt
|
|
7
|
+
- Worktrees contain only git-tracked files. `hooks/`, `settings.json`, `settings.local.json` live in `$CLAUDE_PROJECT_DIR/.claude/` and are shared
|
|
8
|
+
- `ExitWorktree(remove)` requires `discard_changes=true` if there are commits not in main
|
|
9
|
+
- For PRs in review prefer `ExitWorktree(keep)` until merge
|
|
10
|
+
- The Stop hook blocks session end in a worktree — ask the user to choose:
|
|
11
|
+
1. **Push + PR**: commit all → `git push -u origin <branch>` → `gh pr create` → `ExitWorktree(keep)`
|
|
12
|
+
2. **Keep**: `ExitWorktree(keep)` — worktree stays for later
|
|
13
|
+
3. **Discard**: `ExitWorktree(remove, discard_changes=true)`
|
dimsechord-0.3.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Denis Nesterov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dimsechord
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Pure-Python building blocks for DICOM and DICOMweb services: DIMSE networking, caching, and DICOM↔JSON conversion.
|
|
5
|
+
Project-URL: Homepage, https://github.com/radionest/dimsechord
|
|
6
|
+
Project-URL: Repository, https://github.com/radionest/dimsechord
|
|
7
|
+
Project-URL: Issues, https://github.com/radionest/dimsechord/issues
|
|
8
|
+
Author: Denis Nesterov
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: dicom,dicomweb,dimse,medical-imaging,pacs,pydicom,pynetdicom,wado
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Healthcare Industry
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
20
|
+
Classifier: Topic :: System :: Networking
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Requires-Dist: cachetools>=7.0.0
|
|
24
|
+
Requires-Dist: pydicom>=2.3.1
|
|
25
|
+
Requires-Dist: pynetdicom>=3.0.4
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# dimsechord
|
|
29
|
+
|
|
30
|
+
Pure-Python toolkit of composable building blocks for DICOM and DICOMweb services.
|
|
31
|
+
|
|
32
|
+
`dimsechord` wraps [`pydicom`](https://github.com/pydicom/pydicom) and
|
|
33
|
+
[`pynetdicom`](https://github.com/pydicom/pynetdicom) behind a small, stable
|
|
34
|
+
public API. It gives you the networking, caching, and conversion primitives
|
|
35
|
+
needed to build PACS proxies, DICOMweb gateways, and imaging pipelines — and
|
|
36
|
+
nothing else: there is no bundled HTTP framework and no opinionated request
|
|
37
|
+
handlers, so you bring your own web layer.
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- **DIMSE SCU** — C-FIND, C-STORE, C-MOVE and C-GET, exposed through an async
|
|
42
|
+
`DicomClient`.
|
|
43
|
+
- **C-STORE SCP** — receive incoming instances with `StorageSCP`.
|
|
44
|
+
- **AssociationPool** — manage multiple AE-Title identities with per-AET
|
|
45
|
+
concurrency limits.
|
|
46
|
+
- **Two-tier cache** — in-memory + disk, backed by a SQLite instance index.
|
|
47
|
+
- **Streaming pull-engine** — move-to-self retrieval streamed instance by
|
|
48
|
+
instance, with per-UID request coalescing.
|
|
49
|
+
- **DICOMweb conversion** — DICOM ↔ DICOMweb JSON and `multipart/related`
|
|
50
|
+
frame responses.
|
|
51
|
+
|
|
52
|
+
Runtime dependencies are just `pydicom`, `pynetdicom`, and `cachetools`.
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install dimsechord
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Requires Python 3.12+.
|
|
61
|
+
|
|
62
|
+
## Quickstart
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import asyncio
|
|
66
|
+
|
|
67
|
+
from dimsechord import (
|
|
68
|
+
AssociationPool, DicomCache, DicomClient, DicomNode, PullEngine,
|
|
69
|
+
SeriesQuery, StorageSCP, StudyQuery, convert_datasets_to_dicom_json,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
PACS = DicomNode(aet="PACS", host="127.0.0.1", port=11112)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def main() -> None:
|
|
76
|
+
# 1. Find studies and series (C-FIND, QIDO-style).
|
|
77
|
+
client = DicomClient(calling_aet="MYSCU")
|
|
78
|
+
studies = await client.find_studies(StudyQuery(patient_id="12345"), PACS)
|
|
79
|
+
series = await client.find_series(
|
|
80
|
+
SeriesQuery(study_instance_uid=studies[0].study_instance_uid), PACS)
|
|
81
|
+
|
|
82
|
+
# 2. Pull a series move-to-self and build DICOMweb JSON (WADO-style).
|
|
83
|
+
pool = AssociationPool(aets=["MYDEST"])
|
|
84
|
+
scp = StorageSCP()
|
|
85
|
+
scp.start(aets=pool.aets, port=11113)
|
|
86
|
+
# Your PACS must route the AET "MYDEST" back to this SCP's host:port.
|
|
87
|
+
cache = DicomCache(base_dir="./cache", index_path="./cache/index.db")
|
|
88
|
+
engine = PullEngine(pool=pool, scp=scp, cache=cache, pacs=PACS)
|
|
89
|
+
try:
|
|
90
|
+
cached = await engine.ensure_series(
|
|
91
|
+
studies[0].study_instance_uid, series[0].series_instance_uid)
|
|
92
|
+
metadata = convert_datasets_to_dicom_json(
|
|
93
|
+
list(cached.instances.values()), base_url="https://example.org/dicom-web")
|
|
94
|
+
print(len(metadata), "instances")
|
|
95
|
+
finally:
|
|
96
|
+
scp.stop()
|
|
97
|
+
cache.shutdown()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
asyncio.run(main())
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Public API
|
|
104
|
+
|
|
105
|
+
The supported surface is exactly what `dimsechord/__init__.py` exports
|
|
106
|
+
(`from dimsechord import …`); it is frozen by `tests/unit/test_public_api.py`.
|
|
107
|
+
|
|
108
|
+
Everything else lives in underscore-prefixed modules (`dimsechord._scu`,
|
|
109
|
+
`dimsechord._cache`, …) and is **private** — importing from those modules is
|
|
110
|
+
unsupported and may break without notice. If you need something that is not
|
|
111
|
+
exported, extend the public surface rather than reaching into a private module.
|
|
112
|
+
|
|
113
|
+
## Documentation
|
|
114
|
+
|
|
115
|
+
- [Why dimsechord](docs/why.md) — what problems it solves.
|
|
116
|
+
- [Typing](docs/typing.md) — how the typed API works with mypy/pyright.
|
|
117
|
+
- [Cookbook](docs/cookbook.md) — one recipe per feature.
|
|
118
|
+
- [Gateway tutorial](docs/tutorial-gateway.md) — an end-to-end DICOMweb gateway.
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT — see [LICENSE](https://github.com/radionest/dimsechord/blob/main/LICENSE).
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# dimsechord
|
|
2
|
+
|
|
3
|
+
Pure-Python toolkit of composable building blocks for DICOM and DICOMweb services.
|
|
4
|
+
|
|
5
|
+
`dimsechord` wraps [`pydicom`](https://github.com/pydicom/pydicom) and
|
|
6
|
+
[`pynetdicom`](https://github.com/pydicom/pynetdicom) behind a small, stable
|
|
7
|
+
public API. It gives you the networking, caching, and conversion primitives
|
|
8
|
+
needed to build PACS proxies, DICOMweb gateways, and imaging pipelines — and
|
|
9
|
+
nothing else: there is no bundled HTTP framework and no opinionated request
|
|
10
|
+
handlers, so you bring your own web layer.
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- **DIMSE SCU** — C-FIND, C-STORE, C-MOVE and C-GET, exposed through an async
|
|
15
|
+
`DicomClient`.
|
|
16
|
+
- **C-STORE SCP** — receive incoming instances with `StorageSCP`.
|
|
17
|
+
- **AssociationPool** — manage multiple AE-Title identities with per-AET
|
|
18
|
+
concurrency limits.
|
|
19
|
+
- **Two-tier cache** — in-memory + disk, backed by a SQLite instance index.
|
|
20
|
+
- **Streaming pull-engine** — move-to-self retrieval streamed instance by
|
|
21
|
+
instance, with per-UID request coalescing.
|
|
22
|
+
- **DICOMweb conversion** — DICOM ↔ DICOMweb JSON and `multipart/related`
|
|
23
|
+
frame responses.
|
|
24
|
+
|
|
25
|
+
Runtime dependencies are just `pydicom`, `pynetdicom`, and `cachetools`.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install dimsechord
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Requires Python 3.12+.
|
|
34
|
+
|
|
35
|
+
## Quickstart
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import asyncio
|
|
39
|
+
|
|
40
|
+
from dimsechord import (
|
|
41
|
+
AssociationPool, DicomCache, DicomClient, DicomNode, PullEngine,
|
|
42
|
+
SeriesQuery, StorageSCP, StudyQuery, convert_datasets_to_dicom_json,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
PACS = DicomNode(aet="PACS", host="127.0.0.1", port=11112)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def main() -> None:
|
|
49
|
+
# 1. Find studies and series (C-FIND, QIDO-style).
|
|
50
|
+
client = DicomClient(calling_aet="MYSCU")
|
|
51
|
+
studies = await client.find_studies(StudyQuery(patient_id="12345"), PACS)
|
|
52
|
+
series = await client.find_series(
|
|
53
|
+
SeriesQuery(study_instance_uid=studies[0].study_instance_uid), PACS)
|
|
54
|
+
|
|
55
|
+
# 2. Pull a series move-to-self and build DICOMweb JSON (WADO-style).
|
|
56
|
+
pool = AssociationPool(aets=["MYDEST"])
|
|
57
|
+
scp = StorageSCP()
|
|
58
|
+
scp.start(aets=pool.aets, port=11113)
|
|
59
|
+
# Your PACS must route the AET "MYDEST" back to this SCP's host:port.
|
|
60
|
+
cache = DicomCache(base_dir="./cache", index_path="./cache/index.db")
|
|
61
|
+
engine = PullEngine(pool=pool, scp=scp, cache=cache, pacs=PACS)
|
|
62
|
+
try:
|
|
63
|
+
cached = await engine.ensure_series(
|
|
64
|
+
studies[0].study_instance_uid, series[0].series_instance_uid)
|
|
65
|
+
metadata = convert_datasets_to_dicom_json(
|
|
66
|
+
list(cached.instances.values()), base_url="https://example.org/dicom-web")
|
|
67
|
+
print(len(metadata), "instances")
|
|
68
|
+
finally:
|
|
69
|
+
scp.stop()
|
|
70
|
+
cache.shutdown()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
asyncio.run(main())
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Public API
|
|
77
|
+
|
|
78
|
+
The supported surface is exactly what `dimsechord/__init__.py` exports
|
|
79
|
+
(`from dimsechord import …`); it is frozen by `tests/unit/test_public_api.py`.
|
|
80
|
+
|
|
81
|
+
Everything else lives in underscore-prefixed modules (`dimsechord._scu`,
|
|
82
|
+
`dimsechord._cache`, …) and is **private** — importing from those modules is
|
|
83
|
+
unsupported and may break without notice. If you need something that is not
|
|
84
|
+
exported, extend the public surface rather than reaching into a private module.
|
|
85
|
+
|
|
86
|
+
## Documentation
|
|
87
|
+
|
|
88
|
+
- [Why dimsechord](docs/why.md) — what problems it solves.
|
|
89
|
+
- [Typing](docs/typing.md) — how the typed API works with mypy/pyright.
|
|
90
|
+
- [Cookbook](docs/cookbook.md) — one recipe per feature.
|
|
91
|
+
- [Gateway tutorial](docs/tutorial-gateway.md) — an end-to-end DICOMweb gateway.
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT — see [LICENSE](https://github.com/radionest/dimsechord/blob/main/LICENSE).
|