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.
Files changed (56) hide show
  1. dimsechord-0.3.0/.claude/hooks/block-branch-switch.sh +40 -0
  2. dimsechord-0.3.0/.claude/hooks/pr-monitor.sh +32 -0
  3. dimsechord-0.3.0/.claude/hooks/pr-watch.sh +58 -0
  4. dimsechord-0.3.0/.claude/hooks/require-worktree-agent.sh +33 -0
  5. dimsechord-0.3.0/.claude/hooks/require-worktree.sh +33 -0
  6. dimsechord-0.3.0/.claude/hooks/worktree-stop.sh +53 -0
  7. dimsechord-0.3.0/.claude/settings.json +60 -0
  8. dimsechord-0.3.0/.github/workflows/release.yml +52 -0
  9. dimsechord-0.3.0/.gitignore +15 -0
  10. dimsechord-0.3.0/CLAUDE.md +13 -0
  11. dimsechord-0.3.0/LICENSE +21 -0
  12. dimsechord-0.3.0/PKG-INFO +122 -0
  13. dimsechord-0.3.0/README.md +95 -0
  14. dimsechord-0.3.0/docs/cookbook.md +227 -0
  15. dimsechord-0.3.0/docs/tutorial-gateway.md +151 -0
  16. dimsechord-0.3.0/docs/typing.md +86 -0
  17. dimsechord-0.3.0/docs/why.md +95 -0
  18. dimsechord-0.3.0/pyproject.toml +78 -0
  19. dimsechord-0.3.0/src/dimsechord/__init__.py +81 -0
  20. dimsechord-0.3.0/src/dimsechord/_cache.py +242 -0
  21. dimsechord-0.3.0/src/dimsechord/_client.py +248 -0
  22. dimsechord-0.3.0/src/dimsechord/_converter.py +185 -0
  23. dimsechord-0.3.0/src/dimsechord/_exceptions.py +25 -0
  24. dimsechord-0.3.0/src/dimsechord/_handlers.py +176 -0
  25. dimsechord-0.3.0/src/dimsechord/_index.py +167 -0
  26. dimsechord-0.3.0/src/dimsechord/_models.py +189 -0
  27. dimsechord-0.3.0/src/dimsechord/_multipart.py +50 -0
  28. dimsechord-0.3.0/src/dimsechord/_pool.py +82 -0
  29. dimsechord-0.3.0/src/dimsechord/_pull_engine.py +392 -0
  30. dimsechord-0.3.0/src/dimsechord/_scp.py +177 -0
  31. dimsechord-0.3.0/src/dimsechord/_scu.py +838 -0
  32. dimsechord-0.3.0/src/dimsechord/py.typed +0 -0
  33. dimsechord-0.3.0/tests/__init__.py +0 -0
  34. dimsechord-0.3.0/tests/conftest.py +56 -0
  35. dimsechord-0.3.0/tests/factories.py +49 -0
  36. dimsechord-0.3.0/tests/fake_pacs.py +167 -0
  37. dimsechord-0.3.0/tests/integration/__init__.py +0 -0
  38. dimsechord-0.3.0/tests/integration/test_client_get.py +36 -0
  39. dimsechord-0.3.0/tests/integration/test_end_to_end.py +57 -0
  40. dimsechord-0.3.0/tests/integration/test_pull_engine.py +113 -0
  41. dimsechord-0.3.0/tests/integration/test_pull_engine_cget.py +106 -0
  42. dimsechord-0.3.0/tests/integration/test_scp_streaming.py +61 -0
  43. dimsechord-0.3.0/tests/integration/test_scu_find.py +77 -0
  44. dimsechord-0.3.0/tests/integration/test_scu_get.py +60 -0
  45. dimsechord-0.3.0/tests/integration/test_scu_move.py +114 -0
  46. dimsechord-0.3.0/tests/unit/__init__.py +0 -0
  47. dimsechord-0.3.0/tests/unit/test_cache.py +78 -0
  48. dimsechord-0.3.0/tests/unit/test_converter.py +63 -0
  49. dimsechord-0.3.0/tests/unit/test_exceptions.py +23 -0
  50. dimsechord-0.3.0/tests/unit/test_handlers_forward.py +59 -0
  51. dimsechord-0.3.0/tests/unit/test_index.py +80 -0
  52. dimsechord-0.3.0/tests/unit/test_models.py +74 -0
  53. dimsechord-0.3.0/tests/unit/test_multipart.py +29 -0
  54. dimsechord-0.3.0/tests/unit/test_pool.py +69 -0
  55. dimsechord-0.3.0/tests/unit/test_public_api.py +131 -0
  56. 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)`
@@ -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).