diff --git a/.github/workflows/agent-debug.yml b/.github/workflows/agent-debug.yml new file mode 100644 index 000000000..dc4f99b56 --- /dev/null +++ b/.github/workflows/agent-debug.yml @@ -0,0 +1,235 @@ +name: Debug Agent + +on: + workflow_run: + workflows: ["Developer Agent", "Security Agent", "Review Agent", "Revise Agent", "Merge Agent"] + types: [completed] + +permissions: + contents: read + pull-requests: write + issues: write + actions: write + id-token: write + +jobs: + debug: + if: github.event.workflow_run.conclusion == 'failure' + runs-on: + - codebuild-github-runner-python-docx-${{ github.run_id }}-${{ github.run_attempt }} + timeout-minutes: 15 + env: + ANTHROPIC_MODEL: au.anthropic.claude-sonnet-4-6 + CLAUDE_CODE_USE_BEDROCK: "1" + + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN_BEDROCK }} + aws-region: ap-southeast-2 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Install Claude Code + run: npm install -g @anthropic-ai/claude-code + + - name: Set up non-root user for Claude Code + run: | + useradd -m agent 2>/dev/null || true + chown -R agent:agent /tmp + + - name: Collect failure context + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + WORKFLOW_NAME: ${{ github.event.workflow_run.name }} + run: | + echo "=== Failed Workflow ===" > /tmp/debug-context.txt + echo "Workflow: $WORKFLOW_NAME" >> /tmp/debug-context.txt + echo "Run ID: $RUN_ID" >> /tmp/debug-context.txt + echo "URL: ${{ github.event.workflow_run.html_url }}" >> /tmp/debug-context.txt + echo "" >> /tmp/debug-context.txt + + # Get the failed job logs + echo "=== Failed Job Logs (last 200 lines) ===" >> /tmp/debug-context.txt + gh run view "$RUN_ID" --log-failed 2>&1 | tail -200 >> /tmp/debug-context.txt + + # Get retry count from previous debug runs + RETRY_COUNT=$(gh run list --workflow "Debug Agent" --json conclusion,createdAt \ + --jq "[.[] | select(.conclusion == \"success\")] | length" 2>/dev/null || echo "0") + echo "RETRY_COUNT=$RETRY_COUNT" >> $GITHUB_ENV + + # Extract linked issue/PR number from the workflow run + echo "=== Run Details ===" >> /tmp/debug-context.txt + gh run view "$RUN_ID" --json headBranch,event -q '{branch: .headBranch, event: .event}' >> /tmp/debug-context.txt 2>/dev/null || true + + - name: Diagnose with Claude Code + env: + CLAUDE_CODE_USE_BEDROCK: "1" + RUN_ID: ${{ github.event.workflow_run.id }} + WORKFLOW_NAME: ${{ github.event.workflow_run.name }} + run: | + cat > /tmp/debug-prompt.txt </dev/null || \ + cat /tmp/debug-prompt.txt | claude -p \ + --model au.anthropic.claude-sonnet-4-6 \ + --max-turns 5 \ + --allow-dangerously-skip-permissions \ + --allow-dangerously-skip-permissions --dangerously-skip-permissions + + - name: Process diagnosis and take action + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + WORKFLOW_NAME: ${{ github.event.workflow_run.name }} + run: | + if [ ! -f /tmp/diagnosis.json ]; then + echo "No diagnosis produced" + exit 0 + fi + + CATEGORY=$(python3 -c "import sys,json; print(json.load(sys.stdin).get('category','UNKNOWN'))" < /tmp/diagnosis.json 2>/dev/null || echo "UNKNOWN") + SUMMARY=$(python3 -c "import sys,json; print(json.load(sys.stdin).get('summary','Analysis failed'))" < /tmp/diagnosis.json 2>/dev/null || echo "Analysis failed") + DETAILS=$(python3 -c "import sys,json; print(json.load(sys.stdin).get('details',''))" < /tmp/diagnosis.json 2>/dev/null || echo "") + FIX=$(python3 -c "import sys,json; print(json.load(sys.stdin).get('suggested_fix',''))" < /tmp/diagnosis.json 2>/dev/null || echo "") + + echo "Category: $CATEGORY" + echo "Summary: $SUMMARY" + + # Extract issue number and PR number from the failed run + BRANCH=$(gh run view "$RUN_ID" --json headBranch -q .headBranch 2>/dev/null || echo "") + ISSUE_NUM=$(echo "$BRANCH" | grep -oP 'issue-\K\d+' || echo "") + PR_NUM=$(gh pr list --head "$BRANCH" --json number -q '.[0].number' 2>/dev/null || echo "") + + # Count how many times the debug agent has acted on this issue + DEBUG_COUNT=0 + if [ -n "$ISSUE_NUM" ]; then + DEBUG_COUNT=$(gh api repos/${{ github.repository }}/issues/$ISSUE_NUM/comments \ + --jq '[.[] | select(.body | startswith("**Debug Agent**"))] | length' 2>/dev/null || echo "0") + fi + echo "Debug interventions so far: $DEBUG_COUNT" + + # Post diagnosis comment + TARGET="$ISSUE_NUM" + [ -z "$TARGET" ] && TARGET="$PR_NUM" + if [ -n "$TARGET" ]; then + gh issue comment "$TARGET" --body "**Debug Agent** — \`$WORKFLOW_NAME\` failed (intervention $((DEBUG_COUNT + 1))/5) + + **Category:** $CATEGORY + **Summary:** $SUMMARY + $DETAILS + **Action:** $( + if [ "$DEBUG_COUNT" -ge 5 ]; then + echo "Max interventions reached. Requesting human review." + elif [ "$CATEGORY" = "TIMEOUT" ]; then + echo "Re-dispatching developer agent to continue work." + elif [ "$CATEGORY" = "CODE" ]; then + echo "Re-dispatching developer agent with error context." + elif [ "$CATEGORY" = "INFRA" ] || [ "$CATEGORY" = "MODEL" ]; then + echo "Retrying the failed workflow." + else + echo "Requesting human review." + fi + )" 2>/dev/null || true + fi + + # Stop after 5 interventions on the same issue + if [ "$DEBUG_COUNT" -ge 5 ]; then + echo "Max debug interventions reached" + [ -n "$ISSUE_NUM" ] && gh issue edit "$ISSUE_NUM" --add-label "needs-human-review" 2>/dev/null || true + exit 0 + fi + + # Take action based on category and which workflow failed + case "$CATEGORY" in + INFRA|MODEL) + echo "Retrying failed run..." + gh run rerun "$RUN_ID" --failed 2>/dev/null || true + ;; + TIMEOUT) + # Agent ran out of turns — re-dispatch to continue where it left off + if echo "$WORKFLOW_NAME" | grep -qi "Developer"; then + [ -n "$ISSUE_NUM" ] && echo "Re-dispatching Developer Agent..." && \ + GH_TOKEN=${{ github.token }} gh workflow run agent-develop.yml -f issue_number="$ISSUE_NUM" 2>/dev/null || true + elif echo "$WORKFLOW_NAME" | grep -qi "Revise"; then + [ -n "$PR_NUM" ] && echo "Re-dispatching Revise Agent..." && \ + GH_TOKEN=${{ github.token }} gh workflow run agent-revise.yml -f pr_number="$PR_NUM" 2>/dev/null || true + elif echo "$WORKFLOW_NAME" | grep -qi "Security"; then + [ -n "$PR_NUM" ] && echo "Re-dispatching Security Agent..." && \ + GH_TOKEN=${{ github.token }} gh workflow run agent-security.yml -f pr_number="$PR_NUM" 2>/dev/null || true + elif echo "$WORKFLOW_NAME" | grep -qi "Review"; then + [ -n "$PR_NUM" ] && echo "Re-dispatching Review Agent..." && \ + GH_TOKEN=${{ github.token }} gh workflow run agent-review.yml -f pr_number="$PR_NUM" 2>/dev/null || true + else + gh run rerun "$RUN_ID" --failed 2>/dev/null || true + fi + ;; + CODE|PROMPT) + # Code or prompt issue — re-dispatch the developer/revise agent + # The existing branch has partial work; the agent will continue from there + if echo "$WORKFLOW_NAME" | grep -qi "Developer"; then + [ -n "$ISSUE_NUM" ] && echo "Re-dispatching Developer Agent with error context..." && \ + GH_TOKEN=${{ github.token }} gh workflow run agent-develop.yml -f issue_number="$ISSUE_NUM" 2>/dev/null || true + elif echo "$WORKFLOW_NAME" | grep -qi "Revise"; then + [ -n "$PR_NUM" ] && echo "Re-dispatching Revise Agent..." && \ + GH_TOKEN=${{ github.token }} gh workflow run agent-revise.yml -f pr_number="$PR_NUM" 2>/dev/null || true + elif echo "$WORKFLOW_NAME" | grep -qi "Security"; then + # Security agent code failure is unusual — retry + [ -n "$PR_NUM" ] && GH_TOKEN=${{ github.token }} gh workflow run agent-security.yml -f pr_number="$PR_NUM" 2>/dev/null || true + else + gh run rerun "$RUN_ID" --failed 2>/dev/null || true + fi + ;; + *) + # Unknown — try a simple retry first, then human review + if [ "$DEBUG_COUNT" -lt 2 ]; then + gh run rerun "$RUN_ID" --failed 2>/dev/null || true + else + [ -n "$ISSUE_NUM" ] && gh issue edit "$ISSUE_NUM" --add-label "needs-human-review" 2>/dev/null || true + fi + ;; + esac diff --git a/.github/workflows/agent-develop.yml b/.github/workflows/agent-develop.yml new file mode 100644 index 000000000..232520bdc --- /dev/null +++ b/.github/workflows/agent-develop.yml @@ -0,0 +1,164 @@ +name: Developer Agent + +on: + issues: + types: [labeled] + workflow_dispatch: + inputs: + issue_number: + description: "Issue number to implement" + required: true + type: string + +concurrency: + group: agent-develop-${{ github.event.inputs.issue_number || github.event.issue.number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: write + issues: write + actions: write + id-token: write + +jobs: + develop: + if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'product-approved' + runs-on: + - codebuild-github-runner-python-docx-${{ github.run_id }}-${{ github.run_attempt }} + timeout-minutes: 45 + env: + ANTHROPIC_MODEL: au.anthropic.claude-opus-4-6-v1 + CLAUDE_CODE_USE_BEDROCK: "1" + ISSUE_NUMBER: ${{ github.event.inputs.issue_number || github.event.issue.number }} + + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token }} + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN_BEDROCK }} + aws-region: ap-southeast-2 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + run: pip install uv + + - name: Install dependencies + run: uv sync --group dev + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Fetch issue details + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh issue view "$ISSUE_NUMBER" --json title,body --jq '.title' > /tmp/issue-title.txt + gh issue view "$ISSUE_NUMBER" --json title,body --jq '.body' > /tmp/issue-body.txt + + - name: Create feature branch + run: | + BRANCH="agent/issue-${ISSUE_NUMBER}" + git checkout -b "$BRANCH" + echo "BRANCH=$BRANCH" >> $GITHUB_ENV + + - name: Set up non-root user for Claude Code + run: | + useradd -m agent + chown -R agent:agent "$GITHUB_WORKSPACE" + chown -R agent:agent /tmp + git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Install Claude Code + run: npm install -g @anthropic-ai/claude-code + + - name: Write prompt file + run: | + ISSUE_TITLE=$(cat /tmp/issue-title.txt) + ISSUE_BODY=$(cat /tmp/issue-body.txt) + cat > /tmp/prompt.txt <> /tmp/prompt.txt + echo "$ISSUE_BODY" >> /tmp/prompt.txt + + - name: Implement feature with Claude Code + env: + CLAUDE_CODE_USE_BEDROCK: "1" + run: | + runuser -u agent -- bash -c 'cat /tmp/prompt.txt | claude -p \ + --model au.anthropic.claude-opus-4-6-v1 \ + --max-turns 200 \ + --dangerously-skip-permissions' + + - name: Push branch + run: | + git remote set-url origin "https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/${{ github.repository }}.git" + # Revert any workflow file changes (committed or staged) — App token can't push workflow files + git checkout origin/master -- .github/workflows/ 2>/dev/null || git checkout HEAD~1 -- .github/workflows/ 2>/dev/null || true + git diff --cached --quiet .github/workflows/ 2>/dev/null || git commit --amend --no-edit 2>/dev/null || true + git push --force origin "$BRANCH" + + - name: Create Pull Request + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + ISSUE_NUMBER: ${{ github.event.inputs.issue_number || github.event.issue.number }} + run: | + ISSUE_TITLE=$(cat /tmp/issue-title.txt) + ISSUE_BODY=$(cat /tmp/issue-body.txt) + gh pr create \ + --title "feat: ${ISSUE_TITLE}" \ + --body "## Summary + Implements #${ISSUE_NUMBER} + + This PR was automatically generated by the Developer Agent. + + ## Original Issue + ${ISSUE_BODY} + + --- + Generated by Developer Agent using Claude Code" \ + --head "$BRANCH" \ + --base master \ + --label "agent-pr" + + - name: Comment on issue + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + ISSUE_NUMBER: ${{ github.event.inputs.issue_number || github.event.issue.number }} + run: | + PR_URL=$(gh pr view "$BRANCH" --json url -q .url) + gh issue comment "$ISSUE_NUMBER" \ + --body "**Developer Agent** has created a PR: $PR_URL + + The security and review agents will now evaluate the changes." diff --git a/.github/workflows/agent-merge.yml b/.github/workflows/agent-merge.yml new file mode 100644 index 000000000..84286ef11 --- /dev/null +++ b/.github/workflows/agent-merge.yml @@ -0,0 +1,97 @@ +name: Merge Agent + +on: + pull_request: + types: [labeled] + workflow_dispatch: + inputs: + pr_number: + description: "PR number to merge" + required: true + type: string + +concurrency: + group: agent-merge-${{ github.event.inputs.pr_number || github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: write + issues: write + actions: write + id-token: write + +jobs: + merge: + if: | + github.event_name == 'workflow_dispatch' || + (github.event.label.name == 'review-approved' && + contains(github.event.pull_request.labels.*.name, 'agent-pr')) + runs-on: + - codebuild-github-runner-python-docx-${{ github.run_id }}-${{ github.run_attempt }} + timeout-minutes: 10 + env: + PR_NUM: ${{ github.event.inputs.pr_number || github.event.pull_request.number }} + + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Merge PR + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh pr merge "$PR_NUM" \ + --repo ${{ github.repository }} \ + --squash \ + --delete-branch + + - name: Close linked issue and trigger dependents + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + ISSUE_NUM=$(gh pr view "$PR_NUM" \ + --repo ${{ github.repository }} \ + --json body -q .body | grep -oP 'Implements #\K\d+' | head -1) + if [ -n "$ISSUE_NUM" ]; then + gh issue close "$ISSUE_NUM" \ + --repo ${{ github.repository }} \ + --comment "**Merge Agent**: PR #${PR_NUM} merged." + + # Check for dependent issues and trigger them if all deps resolved + OPEN_ISSUES=$(gh issue list --repo ${{ github.repository }} --state open --limit 100 --json number,body -q '.[]') + echo "$OPEN_ISSUES" | python3 -c " + import json, subprocess, re, sys + repo = '${{ github.repository }}' + closed_num = int('${ISSUE_NUM}') + for line in sys.stdin: + line = line.strip() + if not line: continue + issue = json.loads(line) + body = issue.get('body', '') or '' + num = issue['number'] + dep_refs = set() + for m in re.finditer(r'[Dd]epends?\s+on[:\s]*#(\d+)', body): + dep_refs.add(int(m.group(1))) + for m in re.finditer(r'[Dd]epends?\s+on[:\s]*#(\d+)\s*[-]+\s*#?(\d+)', body): + for i in range(int(m.group(1)), int(m.group(2)) + 1): + dep_refs.add(i) + if closed_num not in dep_refs: continue + print(f'Issue #{num} depends on #{closed_num}') + all_ok = True + for d in dep_refs: + r = subprocess.run(['gh','issue','view',str(d),'--repo',repo,'--json','state','-q','.state'], capture_output=True, text=True) + if r.stdout.strip() != 'CLOSED': + print(f' #{d} still open'); all_ok = False; break + if all_ok: + print(f' All deps resolved — dispatching #{num}') + subprocess.run(['gh','workflow','run','agent-develop.yml','--repo',repo,'-f',f'issue_number={num}']) + " || true + fi diff --git a/.github/workflows/agent-product.yml b/.github/workflows/agent-product.yml new file mode 100644 index 000000000..193b54320 --- /dev/null +++ b/.github/workflows/agent-product.yml @@ -0,0 +1,181 @@ +name: Product Agent + +on: + issues: + types: [labeled] + issue_comment: + types: [created] + workflow_dispatch: + inputs: + issue_number: + description: "Issue number to review" + required: true + type: string + +concurrency: + group: agent-product-${{ github.event.inputs.issue_number || github.event.issue.number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + issues: write + actions: write + id-token: write + +jobs: + review: + if: | + github.event_name == 'workflow_dispatch' || + github.event.label.name == 'agent' || + (github.event_name == 'issue_comment' && + !contains(github.event.comment.user.login, '[bot]') && + contains(toJSON(github.event.issue.labels), 'agent')) + runs-on: + - codebuild-github-runner-python-docx-${{ github.run_id }}-${{ github.run_attempt }} + timeout-minutes: 15 + env: + ANTHROPIC_MODEL: au.anthropic.claude-sonnet-4-6 + CLAUDE_CODE_USE_BEDROCK: "1" + ISSUE_NUMBER: ${{ github.event.inputs.issue_number || github.event.issue.number }} + + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token }} + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN_BEDROCK }} + aws-region: ap-southeast-2 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Set up non-root user for Claude Code + run: | + useradd -m agent + chown -R agent:agent "$GITHUB_WORKSPACE" + chown -R agent:agent /tmp + git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Install Claude Code + run: npm install -g @anthropic-ai/claude-code + + - name: Fetch issue details + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh issue view "$ISSUE_NUMBER" --json title,body --jq '.title' > /tmp/issue-title.txt + gh issue view "$ISSUE_NUMBER" --json title,body --jq '.body' > /tmp/issue-body.txt + + # Append recent comments so the Product Agent has full context + COMMENT_COUNT=$(gh api repos/${{ github.repository }}/issues/$ISSUE_NUMBER/comments --jq 'length' 2>/dev/null || echo "0") + if [ "$COMMENT_COUNT" -gt 0 ]; then + echo "" >> /tmp/issue-body.txt + echo "---" >> /tmp/issue-body.txt + echo "## Discussion ($COMMENT_COUNT comments)" >> /tmp/issue-body.txt + gh api repos/${{ github.repository }}/issues/$ISSUE_NUMBER/comments \ + --jq '.[-10:] | .[] | "**@\(.user.login)** (\(.created_at[:10])):\n\(.body[:500])\n"' >> /tmp/issue-body.txt + fi + + - name: Write prompt file + run: | + ISSUE_TITLE=$(cat /tmp/issue-title.txt) + ISSUE_BODY=$(cat /tmp/issue-body.txt) + cat > /tmp/prompt.txt <> /tmp/prompt.txt + echo "$ISSUE_BODY" >> /tmp/prompt.txt + + - name: Review issue with Claude Code + id: review + env: + CLAUDE_CODE_USE_BEDROCK: "1" + run: | + OUTPUT=$(runuser -u agent -- bash -c 'cat /tmp/prompt.txt | claude -p \ + --model au.anthropic.claude-sonnet-4-6 \ + --max-turns 100 \ + --dangerously-skip-permissions') + echo "$OUTPUT" > /tmp/review-output.txt + VERDICT=$(echo "$OUTPUT" | head -1 | tr -d '[:space:]') + echo "verdict=$VERDICT" >> $GITHUB_OUTPUT + echo "Verdict: $VERDICT" + + - name: Handle APPROVED + if: steps.review.outputs.verdict == 'APPROVED' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + REVIEW=$(cat /tmp/review-output.txt) + gh issue comment "$ISSUE_NUMBER" \ + --body "**Product Agent — APPROVED** + + $REVIEW + + Handing off to the Developer Agent." + gh issue label add "$ISSUE_NUMBER" --label "product-approved" + gh workflow run agent-develop.yml -f issue_number="$ISSUE_NUMBER" + + - name: Handle NEEDS_CLARIFICATION + if: steps.review.outputs.verdict == 'NEEDS_CLARIFICATION' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + REVIEW=$(cat /tmp/review-output.txt) + gh issue comment "$ISSUE_NUMBER" \ + --body "**Product Agent — NEEDS CLARIFICATION** + + $REVIEW + + Please update the issue with the requested details and re-apply the \`agent\` label." + gh issue label add "$ISSUE_NUMBER" --label "needs-clarification" + + - name: Handle REJECTED + if: steps.review.outputs.verdict == 'REJECTED' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + REVIEW=$(cat /tmp/review-output.txt) + gh issue comment "$ISSUE_NUMBER" \ + --body "**Product Agent — REJECTED** + + $REVIEW" + gh issue label add "$ISSUE_NUMBER" --label "product-rejected" + gh issue label remove "$ISSUE_NUMBER" --label "agent" diff --git a/.github/workflows/agent-review.yml b/.github/workflows/agent-review.yml new file mode 100644 index 000000000..45976585c --- /dev/null +++ b/.github/workflows/agent-review.yml @@ -0,0 +1,131 @@ +name: Review Agent + +on: + pull_request: + types: [labeled] + workflow_dispatch: + inputs: + pr_number: + description: "PR number to review" + required: true + type: string + +concurrency: + group: agent-review-${{ github.event.inputs.pr_number || github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + actions: write + id-token: write + +jobs: + review: + if: | + github.event_name == 'workflow_dispatch' || + (github.event.label.name == 'security-passed' && + contains(github.event.pull_request.labels.*.name, 'agent-pr')) + runs-on: + - codebuild-github-runner-python-docx-${{ github.run_id }}-${{ github.run_attempt }} + timeout-minutes: 15 + env: + ANTHROPIC_MODEL: au.anthropic.claude-sonnet-4-6 + PR_NUM: ${{ github.event.inputs.pr_number || github.event.pull_request.number }} + + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + + - name: Checkout PR branch (workflow_dispatch) + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + HEAD_REF=$(gh pr view "$PR_NUM" --json headRefName -q .headRefName) + git fetch origin "$HEAD_REF" + git checkout "$HEAD_REF" + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN_BEDROCK }} + aws-region: ap-southeast-2 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Set up non-root user for Claude Code + run: | + useradd -m agent + chown -R agent:agent "$GITHUB_WORKSPACE" + chown -R agent:agent /tmp + git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Install Claude Code + run: npm install -g @anthropic-ai/claude-code + + - name: Code review with Claude Code + env: + CLAUDE_CODE_USE_BEDROCK: "1" + run: | + runuser -u agent -- claude -p \ + --model au.anthropic.claude-sonnet-4-6 \ + --max-turns 30 \ + --allow-dangerously-skip-permissions --dangerously-skip-permissions \ + "You are a code review agent reviewing PR #${PR_NUM}. + + ## Instructions + 1. Read CLAUDE.md if it exists for project conventions. + 2. Run \`git diff origin/master...HEAD\` to see all changes. + 3. Review for code quality, correctness, and adherence to project conventions. + + ## Check for: + - **Correctness**: Logic errors, off-by-one, race conditions, missing edge cases + - **Conventions**: Does it follow patterns in existing code? + - **Code quality**: Duplication, unclear naming, unnecessary complexity + - **Error handling**: Missing error handling at system boundaries + - **Tests**: Are new features tested? Are existing tests still valid? + - **Performance**: Unnecessary allocations, O(n^2) where O(n) would do + + ## Output + Create a file called /tmp/review-report.md with your findings: + - If APPROVED: Start with REVIEW_APPROVED on the first line, then brief positive notes. + - If CHANGES NEEDED: Start with REVIEW_CHANGES_NEEDED on the first line, then specific, actionable feedback with file paths and line numbers. + + Be constructive. Only request changes for real issues, not style preferences." + + - name: Post review + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + if [ -f /tmp/review-report.md ]; then + REPORT=$(cat /tmp/review-report.md) + else + REPORT="Review agent completed but did not produce a report." + fi + + gh pr comment "$PR_NUM" \ + --body "**Review Agent** + + $REPORT" + + if echo "$REPORT" | grep -q "REVIEW_APPROVED"; then + gh pr edit "$PR_NUM" --add-label "review-approved" + gh workflow run agent-merge.yml -f pr_number="$PR_NUM" + else + gh pr edit "$PR_NUM" --add-label "review-changes-needed" + gh workflow run agent-revise.yml -f pr_number="$PR_NUM" + fi diff --git a/.github/workflows/agent-revise.yml b/.github/workflows/agent-revise.yml new file mode 100644 index 000000000..f593803ba --- /dev/null +++ b/.github/workflows/agent-revise.yml @@ -0,0 +1,152 @@ +name: Revise Agent + +on: + pull_request: + types: [labeled] + workflow_dispatch: + inputs: + pr_number: + description: "PR number to revise" + required: true + type: string + +concurrency: + group: agent-revise-${{ github.event.inputs.pr_number || github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: write + actions: write + id-token: write + +jobs: + revise: + if: | + github.event_name == 'workflow_dispatch' || + (contains(github.event.pull_request.labels.*.name, 'agent-pr') && + (github.event.label.name == 'security-failed' || github.event.label.name == 'review-changes-needed')) + runs-on: + - codebuild-github-runner-python-docx-${{ github.run_id }}-${{ github.run_attempt }} + timeout-minutes: 45 + env: + ANTHROPIC_MODEL: au.anthropic.claude-opus-4-6-v1 + PR_NUM: ${{ github.event.inputs.pr_number || github.event.pull_request.number }} + + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Check revision count + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + REVISION_COUNT=$(gh api repos/${{ github.repository }}/issues/${PR_NUM}/comments \ + --jq '[.[] | select(.body | startswith("**Review Agent**")) | select(.body | contains("REVIEW_CHANGES_NEEDED"))] | length') + if [ "$REVISION_COUNT" -gt 2 ]; then + gh pr comment "$PR_NUM" \ + --body "**Revise Agent**: Maximum revision cycles (2) reached. Requesting human review." + gh pr edit "$PR_NUM" --add-label "needs-human-review" + exit 1 + fi + + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + token: ${{ steps.app-token.outputs.token }} + + - name: Checkout PR branch (workflow_dispatch) + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + HEAD_REF=$(gh pr view "$PR_NUM" --json headRefName -q .headRefName) + git fetch origin "$HEAD_REF" + git checkout "$HEAD_REF" + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN_BEDROCK }} + aws-region: ap-southeast-2 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + run: pip install uv + + - name: Install dependencies + run: uv sync --group dev + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Get review feedback + id: feedback + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + FEEDBACK=$(gh api repos/${{ github.repository }}/issues/${PR_NUM}/comments \ + --jq '[.[] | select(.body | startswith("**Review Agent**") or startswith("**Security Agent"))] | last | .body') + echo "FEEDBACK<> $GITHUB_OUTPUT + echo "$FEEDBACK" >> $GITHUB_OUTPUT + echo "FEEDBACKEOF" >> $GITHUB_OUTPUT + + - name: Set up non-root user for Claude Code + run: | + useradd -m agent + chown -R agent:agent "$GITHUB_WORKSPACE" + chown -R agent:agent /tmp + git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Install Claude Code + run: npm install -g @anthropic-ai/claude-code + + - name: Fix issues with Claude Code + env: + CLAUDE_CODE_USE_BEDROCK: "1" + FEEDBACK: ${{ steps.feedback.outputs.FEEDBACK }} + run: | + runuser -u agent -- claude -p \ + --model au.anthropic.claude-opus-4-6-v1 \ + --max-turns 200 \ + --allow-dangerously-skip-permissions --dangerously-skip-permissions \ + "You are a developer agent fixing issues raised during code review of PR #${PR_NUM}. + + ## Review Feedback + $FEEDBACK + + ## Instructions + 1. Read CLAUDE.md if it exists for project conventions. + 2. Read the review feedback carefully. + 3. Fix each issue raised by the reviewers. + 4. Run tests: \`uv run pytest\` + 5. Run acceptance tests: \`uv run behave --stop\` + 6. Commit your fixes with a message like \"fix: address review feedback\"" + + - name: Push fixes + run: | + git remote set-url origin "https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/${{ github.repository }}.git" + git checkout -- .github/workflows/ 2>/dev/null || true + git push origin HEAD + + - name: Reset labels and re-trigger security + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh pr edit "$PR_NUM" \ + --remove-label "security-failed" \ + --remove-label "review-changes-needed" \ + --remove-label "security-passed" \ + --remove-label "review-approved" 2>/dev/null || true + GH_TOKEN=${{ github.token }} gh workflow run agent-security.yml -f pr_number="$PR_NUM" diff --git a/.github/workflows/agent-security.yml b/.github/workflows/agent-security.yml new file mode 100644 index 000000000..b1d2bf6a2 --- /dev/null +++ b/.github/workflows/agent-security.yml @@ -0,0 +1,129 @@ +name: Security Agent + +on: + pull_request: + types: [opened, synchronize] + workflow_dispatch: + inputs: + pr_number: + description: "PR number to review" + required: true + type: string + +concurrency: + group: agent-security-${{ github.event.inputs.pr_number || github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + actions: write + id-token: write + +jobs: + security: + if: | + github.event_name == 'workflow_dispatch' || + contains(github.event.pull_request.labels.*.name, 'agent-pr') + runs-on: + - codebuild-github-runner-python-docx-${{ github.run_id }}-${{ github.run_attempt }} + timeout-minutes: 15 + env: + ANTHROPIC_MODEL: au.anthropic.claude-sonnet-4-6 + PR_NUM: ${{ github.event.inputs.pr_number || github.event.pull_request.number }} + + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Checkout PR branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + + - name: Checkout PR branch (workflow_dispatch) + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + HEAD_REF=$(gh pr view "$PR_NUM" --json headRefName -q .headRefName) + git fetch origin "$HEAD_REF" + git checkout "$HEAD_REF" + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN_BEDROCK }} + aws-region: ap-southeast-2 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Set up non-root user for Claude Code + run: | + useradd -m agent + chown -R agent:agent "$GITHUB_WORKSPACE" + chown -R agent:agent /tmp + git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Install Claude Code + run: npm install -g @anthropic-ai/claude-code + + - name: Security review with Claude Code + env: + CLAUDE_CODE_USE_BEDROCK: "1" + run: | + runuser -u agent -- claude -p \ + --model au.anthropic.claude-sonnet-4-6 \ + --max-turns 30 \ + --allow-dangerously-skip-permissions --dangerously-skip-permissions \ + "You are a security agent reviewing PR #${PR_NUM}. + + ## Instructions + 1. Read CLAUDE.md if it exists for project context. + 2. Run \`git diff origin/master...HEAD\` to see all changes. + 3. Review EVERY changed file for security issues. + + ## Check for: + - **Injection risks**: XML injection, XXE attacks, path traversal + - **Dependency risks**: New dependencies with known CVEs + - **Data exposure**: Sensitive data leaks, unsafe file handling + - **Secrets in code**: API keys, tokens, passwords + + ## Output + Create a file called /tmp/security-report.md with your findings: + - If CLEAN: Start with SECURITY_PASS on the first line, then your report. + - If ISSUES FOUND: Start with SECURITY_FAIL on the first line, then each issue with file path, line number, severity (HIGH/MEDIUM/LOW), description, and recommended fix. + + Be thorough but avoid false positives. Only flag real security concerns." + + - name: Post security report + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + if [ -f /tmp/security-report.md ]; then + REPORT=$(cat /tmp/security-report.md) + else + REPORT="Security agent completed but did not produce a report." + fi + + gh pr comment "$PR_NUM" \ + --body "**Security Agent Report** + + $REPORT" + + if echo "$REPORT" | grep -q "SECURITY_FAIL"; then + gh pr edit "$PR_NUM" --add-label "security-failed" + gh workflow run agent-revise.yml -f pr_number="$PR_NUM" + exit 1 + else + gh pr edit "$PR_NUM" --add-label "security-passed" + gh workflow run agent-review.yml -f pr_number="$PR_NUM" + fi diff --git a/.github/workflows/agent-watchdog.yml b/.github/workflows/agent-watchdog.yml new file mode 100644 index 000000000..d552b8a32 --- /dev/null +++ b/.github/workflows/agent-watchdog.yml @@ -0,0 +1,159 @@ +name: Pipeline Watchdog + +on: + schedule: + # Run every 30 minutes + - cron: "*/30 * * * *" + workflow_dispatch: {} + +permissions: + contents: read + issues: write + pull-requests: write + actions: write + +jobs: + watchdog: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Check for stalled issues and PRs + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + DISPATCH_TOKEN: ${{ github.token }} + run: | + REPO="${{ github.repository }}" + echo "=== Pipeline Watchdog — $(date -u) ===" + FIXES=0 + + # --- 1. ops-feedback issues: Operator sent back but Product Agent didn't pick up --- + echo "Checking ops-feedback issues..." + for NUM in $(gh issue list --repo $REPO --label "ops-feedback" --state open --json number -q '.[].number'); do + # Always re-dispatch — the Product Agent is idempotent and will skip if already reviewing + echo " Stalled: #$NUM (ops-feedback) — dispatching Product Agent" + GH_TOKEN=$DISPATCH_TOKEN gh workflow run agent-product.yml -f issue_number="$NUM" 2>/dev/null || true + FIXES=$((FIXES + 1)) + sleep 2 + done + + # --- 2. needs-clarification with recent human response --- + echo "Checking needs-clarification issues..." + for NUM in $(gh issue list --repo $REPO --label "needs-clarification" --state open --json number -q '.[].number'); do + # Check if a non-bot commented after the clarification was requested + LAST_HUMAN=$(gh api repos/$REPO/issues/$NUM/comments \ + --jq '[.[] | select(.user.login | contains("[bot]") | not)] | last | .created_at // ""' 2>/dev/null) + LAST_BOT=$(gh api repos/$REPO/issues/$NUM/comments \ + --jq '[.[] | select(.user.login | contains("[bot]"))] | last | .created_at // ""' 2>/dev/null) + if [ -n "$LAST_HUMAN" ] && [ "$LAST_HUMAN" \> "$LAST_BOT" ]; then + echo " Stalled: #$NUM (needs-clarification but human responded) — dispatching" + gh issue edit "$NUM" --remove-label "needs-clarification" 2>/dev/null || true + GH_TOKEN=$DISPATCH_TOKEN gh workflow run agent-product.yml -f issue_number="$NUM" 2>/dev/null || true + FIXES=$((FIXES + 1)) + sleep 2 + fi + done + + # --- 3. Stale in-dev labels (no active Developer Agent run) --- + echo "Checking stale in-dev labels..." + ACTIVE_DEV=$(gh run list --repo $REPO --workflow "Developer Agent" --limit 5 --json status \ + -q '[.[] | select(.status == "in_progress" or .status == "queued")] | length' 2>/dev/null || echo "0") + if [ "$ACTIVE_DEV" = "0" ]; then + for NUM in $(gh issue list --repo $REPO --label "in-dev" --state open --json number -q '.[].number'); do + echo " Stale in-dev: #$NUM — removing label" + gh issue edit "$NUM" --remove-label "in-dev" 2>/dev/null || true + FIXES=$((FIXES + 1)) + done + fi + + # --- 4. PRs with only agent-pr label (stuck before security) --- + echo "Checking PRs stuck before security..." + gh pr list --repo $REPO --state open --json number,labels -q '.[] | select([.labels[].name] == ["agent-pr"]) | .number' | while read -r PR; do + echo " Stalled PR: #$PR (no security label) — dispatching security" + GH_TOKEN=$DISPATCH_TOKEN gh workflow run agent-security.yml -f pr_number="$PR" 2>/dev/null || true + FIXES=$((FIXES + 1)) + sleep 2 + done + + # --- 5. PRs with security-passed but not yet approved (stuck between security and review) --- + echo "Checking PRs stuck between security and review..." + ACTIVE_REVIEW=$(gh run list --repo $REPO --workflow "Review Agent" --limit 5 --json status \ + -q '[.[] | select(.status == "in_progress" or .status == "queued")] | length' 2>/dev/null || echo "0") + if [ "$ACTIVE_REVIEW" = "0" ]; then + gh pr list --repo $REPO --state open --json number,labels \ + -q '.[] | select(([.labels[].name] | contains(["security-passed"])) and ([.labels[].name] | contains(["review-approved"]) | not) and ([.labels[].name] | contains(["review-changes-needed"]) | not)) | .number' | while read -r PR; do + echo " Stalled PR: #$PR (security-passed, no active review) — dispatching review" + gh pr edit "$PR" --repo $REPO --remove-label "in-review" 2>/dev/null || true + GH_TOKEN=$DISPATCH_TOKEN gh workflow run agent-review.yml -f pr_number="$PR" 2>/dev/null || true + FIXES=$((FIXES + 1)) + sleep 2 + done + fi + + # --- 6. PRs with review-approved but not merged (stuck before merge) --- + echo "Checking PRs stuck before merge..." + gh pr list --repo $REPO --state open --json number,labels \ + -q '.[] | select([.labels[].name] | contains(["review-approved"])) | .number' | while read -r PR; do + echo " Stalled PR: #$PR (review-approved, not merged) — dispatching merge" + GH_TOKEN=$DISPATCH_TOKEN gh workflow run agent-merge.yml -f pr_number="$PR" 2>/dev/null || true + FIXES=$((FIXES + 1)) + sleep 2 + done + + # --- 7. PRs with review-changes-needed but no active revise run --- + echo "Checking PRs stuck waiting for revision..." + ACTIVE_REVISE=$(gh run list --repo $REPO --workflow "Revise Agent" --limit 5 --json status \ + -q '[.[] | select(.status == "in_progress" or .status == "queued")] | length' 2>/dev/null || echo "0") + if [ "$ACTIVE_REVISE" = "0" ]; then + gh pr list --repo $REPO --state open --json number,labels \ + -q '.[] | select(([.labels[].name] | contains(["review-changes-needed"])) or ([.labels[].name] | contains(["security-failed"]))) | .number' | while read -r PR; do + echo " Stalled PR: #$PR (needs revision, no active revise run) — dispatching revise" + GH_TOKEN=$DISPATCH_TOKEN gh workflow run agent-revise.yml -f pr_number="$PR" 2>/dev/null || true + FIXES=$((FIXES + 1)) + sleep 2 + done + fi + + # --- 8. Approved issues with no active Developer Agent run and no open PR --- + # Check both ops-approved (CSC) and product-approved (python-docx/docxjs which skip Operator) + echo "Checking approved issues not in development..." + if [ "$ACTIVE_DEV" = "0" ]; then + for LABEL in "ops-approved" "product-approved"; do + for NUM in $(gh issue list --repo $REPO --label "$LABEL" --state open --json number,labels \ + -q '.[] | select([.labels[].name] | contains(["in-dev"]) | not) | .number' 2>/dev/null); do + # Check if an OPEN PR already exists for this issue + PR_EXISTS=$(gh pr list --repo $REPO --state open --head "agent/issue-$NUM" --json number -q 'length' 2>/dev/null || echo "0") + if [ "$PR_EXISTS" = "0" ]; then + echo " Stalled: #$NUM ($LABEL, no open PR, no active dev) — dispatching developer" + GH_TOKEN=$DISPATCH_TOKEN gh workflow run agent-develop.yml -f issue_number="$NUM" 2>/dev/null || true + FIXES=$((FIXES + 1)) + sleep 2 + fi + done + done + fi + + # --- 9. Issues with agent label but no pipeline progress (no other labels, no PR) --- + echo "Checking agent-labeled issues with no pipeline progress..." + if [ "$ACTIVE_DEV" = "0" ]; then + for NUM in $(gh issue list --repo $REPO --label "agent" --state open --json number,labels \ + -q '.[] | select(([.labels[].name] | length) <= 1) | .number' 2>/dev/null); do + PR_EXISTS=$(gh pr list --repo $REPO --state open --head "agent/issue-$NUM" --json number -q 'length' 2>/dev/null || echo "0") + if [ "$PR_EXISTS" = "0" ]; then + echo " Stalled: #$NUM (agent label only, no progress) — dispatching Product Agent" + GH_TOKEN=$DISPATCH_TOKEN gh workflow run agent-product.yml -f issue_number="$NUM" 2>/dev/null || true + FIXES=$((FIXES + 1)) + sleep 2 + fi + done + fi + + echo "" + echo "=== Watchdog complete: $FIXES fixes applied ===" diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..72cbd9393 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,147 @@ +# CLAUDE.md + +python-docx fork (loadfix/python-docx) — extending python-docx with footnotes, endnotes, track changes, fields, bookmarks, and other missing OOXML capabilities. + +## Architecture + +Three-layer pattern: + +``` +Document API (src/docx/document.py, src/docx/footnotes.py, etc.) + | Proxy objects wrapping oxml elements +Parts Layer (src/docx/parts/*.py) + | XmlPart subclasses owning XML trees, managing relationships +oxml Layer (src/docx/oxml/*.py) + | CT_* element classes extending lxml.etree.ElementBase +lxml (XML parsing/serialization) +``` + +## Source Layout + +``` +src/docx/ Main package (src-layout, NOT flat) +src/docx/oxml/ CT_* element classes (low-level XML wrappers) +src/docx/parts/ Part classes (document, numbering, comments, styles, etc.) +src/docx/text/ Text-related proxy classes (paragraph, run, font, parfmt) +src/docx/styles/ Style proxy classes +src/docx/enum/ Enumerations (WD_ALIGN, WD_STYLE_TYPE, etc.) +src/docx/templates/ Default XML templates for new parts +tests/ pytest test suite +features/ behave acceptance tests +``` + +## Key Patterns + +### CT_ Element Classes (oxml layer) + +Define in `src/docx/oxml/`, register in `src/docx/oxml/__init__.py`. + +```python +from docx.oxml.xmlchemy import BaseOxmlElement, ZeroOrOne, ZeroOrMore, OptionalAttribute +from docx.oxml.simpletypes import ST_DecimalNumber, ST_String + +class CT_Footnote(BaseOxmlElement): + """```` element.""" + pPr = ZeroOrOne("w:pPr", successors=("w:r",)) + r = ZeroOrMore("w:r", successors=()) + id = RequiredAttribute("w:id", ST_DecimalNumber) +``` + +- `ZeroOrOne(tag, successors=(...))` — generates getter, `_add_*()`, `get_or_add_*()`, `_remove_*()`, `_insert_*()` +- `ZeroOrMore(tag, successors=(...))` — generates `*_lst` property, `add_*()`, `_insert_*()` +- `successors` tuple must match XSD schema ordering exactly +- Register: `register_element_cls("w:footnote", CT_Footnote)` in `oxml/__init__.py` + +### Part Classes + +Extend `XmlPart` or `StoryPart`. Follow `CommentsPart` as a model: + +```python +class FootnotesPart(StoryPart): + @classmethod + def default(cls, package): + partname = PackURI("/word/footnotes.xml") + content_type = CT.WML_FOOTNOTES + element = cast("CT_Footnotes", parse_xml(cls._default_xml())) + return cls(partname, content_type, element, package) +``` + +Wire into `DocumentPart` with lazy creation: +```python +@property +def _footnotes_part(self): + try: + return self.part_related_by(RT.FOOTNOTES) + except KeyError: + part = FootnotesPart.default(self.package) + self.relate_to(part, RT.FOOTNOTES) + return part +``` + +Register in `src/docx/__init__.py`: +```python +PartFactory.part_type_for[CT.WML_FOOTNOTES] = FootnotesPart +``` + +### Proxy Objects (Document API) + +Wrap CT_ elements. Inherit from `ElementProxy`, `StoryChild`, or `BlockItemContainer`: + +```python +class Footnote(BlockItemContainer): + @property + def footnote_id(self): + return self._element.id +``` + +### Constants + +- Content types: `src/docx/opc/constants.py` — `CT.WML_FOOTNOTES` and `CT.WML_ENDNOTES` already defined +- Relationship types: same file — `RT.FOOTNOTES` and `RT.ENDNOTES` already defined +- Namespaces: `src/docx/oxml/ns.py` — `qn("w:footnote")` for Clark notation + +## Test Conventions + +- Framework: pytest with BDD-style naming +- Test classes: `Describe*` pattern +- Test methods: `it_*`, `its_*`, `they_*` prefixes +- Test XML: `cxml.element("w:footnotes/(w:footnote{w:id=1})")` — compact XML expression language +- Mocks: `class_mock(request, "dotted.path")`, `instance_mock(request, Class)`, `method_mock(request, Class, "name")` +- Test utilities in `tests/unitutil/` + +Example: +```python +class DescribeCT_Footnotes: + def it_can_add_a_footnote(self): + footnotes = cast(CT_Footnotes, element("w:footnotes")) + footnote = footnotes.add_footnote() + assert footnote.id == 2 +``` + +## Commands + +```bash +# Run tests +pytest tests/ -v + +# Run a specific test +pytest tests/unit/test_footnotes.py -v + +# Run acceptance tests +behave features/ + +# Type check +pyright src/ + +# Install in dev mode +pip install -e ".[dev]" +``` + +## Important + +- Always run tests after changes: `pytest tests/ -v` +- The successors tuple in element declarations MUST match XSD ordering +- Footnote IDs 0 and 1 are reserved (separator, continuation separator) +- Use `src/` layout — all code is under `src/docx/`, not `docx/` +- Follow existing code style: no docstring on test methods, BDD-style names +- XML templates go in `src/docx/templates/` diff --git a/MANIFEST.in b/MANIFEST.in index b2d3fadcf..b96d7c09d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,4 @@ -include HISTORY.rst LICENSE README.rst tox.ini -include requirements*.txt +include HISTORY.rst LICENSE README.md tox.ini graft src/docx/templates graft features graft tests diff --git a/Makefile b/Makefile index 2b2fb4121..979ea18e7 100644 --- a/Makefile +++ b/Makefile @@ -29,8 +29,7 @@ build: uv build clean: - # find . -type f -name \*.pyc -exec rm {} \; - fd -e pyc -I -x rm + find . -type f -name '*.pyc' -delete rm -rf dist *.egg-info .coverage .DS_Store cleandocs: @@ -43,7 +42,7 @@ docs: $(MAKE) -C docs html install: - pip install -Ue . + uv pip install -e . opendocs: open docs/.build/html/index.html diff --git a/features/hdr-header-footer.feature b/features/hdr-header-footer.feature index eb2bb00d6..169f6c861 100644 --- a/features/hdr-header-footer.feature +++ b/features/hdr-header-footer.feature @@ -43,7 +43,7 @@ Feature: Header and footer behaviors Scenario: _Header allows image insertion Given a _Run object from a header as run When I call run.add_picture() - Then I can't detect the image but no exception is raised + Then the picture appears at the end of the run Scenario Outline: _Footer.is_linked_to_previous getter @@ -85,4 +85,4 @@ Feature: Header and footer behaviors Scenario: _Footer allows image insertion Given a _Run object from a footer as run When I call run.add_picture() - Then I can't detect the image but no exception is raised + Then the picture appears at the end of the run diff --git a/features/steps/hdrftr.py b/features/steps/hdrftr.py index 5949f961c..15f31444b 100644 --- a/features/steps/hdrftr.py +++ b/features/steps/hdrftr.py @@ -132,6 +132,3 @@ def then_header_2_text_eq_header_text(context): assert actual == expected, "header_2.paragraphs[0].text == %s" % actual -@then("I can't detect the image but no exception is raised") -def then_I_cant_detect_the_image_but_no_exception_is_raised(context): - pass diff --git a/features/steps/text.py b/features/steps/text.py index 9fafc61fc..7374fb5e7 100644 --- a/features/steps/text.py +++ b/features/steps/text.py @@ -1,6 +1,5 @@ """Step implementations for text-related features.""" -import hashlib from behave import given, then, when from behave.runner import Context @@ -262,16 +261,13 @@ def then_last_item_in_run_is_a_break(context): def then_the_picture_appears_at_the_end_of_the_run(context): run = context.run r = run._r - blip_rId = r.xpath( + blip_rIds = r.xpath( "./w:drawing/wp:inline/a:graphic/a:graphicData/pic:pic/pic:blipFill/a:blip/@r:embed" - )[0] - image_part = run.part.related_parts[blip_rId] - image_sha1 = hashlib.sha1(image_part.blob).hexdigest() - expected_sha1 = "79769f1e202add2e963158b532e36c2c0f76a70c" - assert image_sha1 == expected_sha1, "image SHA1 doesn't match, expected %s, got %s" % ( - expected_sha1, - image_sha1, ) + assert len(blip_rIds) > 0, "no inline picture found in run" + blip_rId = blip_rIds[0] + image_part = run.part.related_parts[blip_rId] + assert len(image_part.blob) > 0, "image part has no content" @then("the run appears in {boolean_prop_name} unconditionally") diff --git a/pyproject.toml b/pyproject.toml index b3dc0be02..a2ff484a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,17 +13,18 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Office/Business :: Office Suites", "Topic :: Software Development :: Libraries", ] dependencies = [ - "lxml>=3.1.0", - "typing_extensions>=4.9.0", + "defusedxml>=0.7.0", + "lxml>=4.9.1", + "typing-extensions>=4.9.0", ] description = "Create, read, and update Microsoft Word .docx files." dynamic = ["version"] @@ -68,17 +69,13 @@ venvPath = "." venv = ".venv" [tool.pytest.ini_options] +markers = [ + "libreoffice: marks tests requiring LibreOffice headless (deselect with '-m \"not libreoffice\"')", +] filterwarnings = [ # -- exit on any warning not explicitly ignored here -- "error", - - # -- pytest-xdist plugin may warn about `looponfailroots` deprecation -- - "ignore::DeprecationWarning:xdist", - - # -- pytest complains when pytest-xdist is not installed -- - "ignore:Unknown config option. looponfailroots:pytest.PytestConfigWarning", ] -looponfailroots = ["src", "tests"] norecursedirs = [ "doc", "docx", @@ -96,7 +93,7 @@ python_functions = ["it_", "its_", "they_", "and_", "but_"] [tool.ruff] exclude = [] line-length = 100 -target-version = "py38" +target-version = "py39" [tool.ruff.lint] ignore = [ diff --git a/requirements-test.txt b/requirements-test.txt index b542c1af7..755a99851 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,7 +1,5 @@ -r requirements.txt -behave>=1.2.3 -pyparsing>=2.0.1 -pytest>=2.5 -pytest-coverage -pytest-xdist -ruff +behave>=1.2.6 +pyparsing>=3.2.3 +pytest>=8.4.0 +ruff>=0.11.13 diff --git a/requirements.txt b/requirements.txt index a156cfe60..948152cdd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -lxml>=3.1.0 -typing-extensions +defusedxml>=0.7.0 +lxml>=4.9.1 +typing-extensions>=4.9.0 diff --git a/src/docx/__init__.py b/src/docx/__init__.py index fd06c84d2..755c1713b 100644 --- a/src/docx/__init__.py +++ b/src/docx/__init__.py @@ -27,6 +27,8 @@ from docx.opc.parts.coreprops import CorePropertiesPart from docx.parts.comments import CommentsPart from docx.parts.document import DocumentPart +from docx.parts.endnotes import EndnotesPart +from docx.parts.footnotes import FootnotesPart from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.image import ImagePart from docx.parts.numbering import NumberingPart @@ -44,7 +46,10 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None: PartFactory.part_type_for[CT.OPC_CORE_PROPERTIES] = CorePropertiesPart PartFactory.part_type_for[CT.WML_COMMENTS] = CommentsPart PartFactory.part_type_for[CT.WML_DOCUMENT_MAIN] = DocumentPart +PartFactory.part_type_for[CT.WML_DOCUMENT_MACRO] = DocumentPart +PartFactory.part_type_for[CT.WML_ENDNOTES] = EndnotesPart PartFactory.part_type_for[CT.WML_FOOTER] = FooterPart +PartFactory.part_type_for[CT.WML_FOOTNOTES] = FootnotesPart PartFactory.part_type_for[CT.WML_HEADER] = HeaderPart PartFactory.part_type_for[CT.WML_NUMBERING] = NumberingPart PartFactory.part_type_for[CT.WML_SETTINGS] = SettingsPart @@ -55,7 +60,9 @@ def part_class_selector(content_type: str, reltype: str) -> Type[Part] | None: CorePropertiesPart, CommentsPart, DocumentPart, + EndnotesPart, FooterPart, + FootnotesPart, HeaderPart, NumberingPart, PartFactory, diff --git a/src/docx/api.py b/src/docx/api.py index aea876458..69af0e96d 100644 --- a/src/docx/api.py +++ b/src/docx/api.py @@ -25,7 +25,7 @@ def Document(docx: str | IO[bytes] | None = None) -> DocumentObject: """ docx = _default_docx_path() if docx is None else docx document_part = cast("DocumentPart", Package.open(docx).main_document_part) - if document_part.content_type != CT.WML_DOCUMENT_MAIN: + if document_part.content_type not in (CT.WML_DOCUMENT_MAIN, CT.WML_DOCUMENT_MACRO): tmpl = "file '%s' is not a Word file, content type is '%s'" raise ValueError(tmpl % (docx, document_part.content_type)) return document_part.document diff --git a/src/docx/blkcntnr.py b/src/docx/blkcntnr.py index 82c7ef727..7790890ee 100644 --- a/src/docx/blkcntnr.py +++ b/src/docx/blkcntnr.py @@ -21,13 +21,15 @@ import docx.types as t from docx.oxml.comments import CT_Comment from docx.oxml.document import CT_Body + from docx.oxml.endnotes import CT_Endnote + from docx.oxml.footnotes import CT_Footnote from docx.oxml.section import CT_HdrFtr from docx.oxml.table import CT_Tc from docx.shared import Length from docx.styles.style import ParagraphStyle from docx.table import Table -BlockItemElement: TypeAlias = "CT_Body | CT_Comment | CT_HdrFtr | CT_Tc" +BlockItemElement: TypeAlias = "CT_Body | CT_Comment | CT_Endnote | CT_Footnote | CT_HdrFtr | CT_Tc" class BlockItemContainer(StoryChild): diff --git a/src/docx/bookmarks.py b/src/docx/bookmarks.py new file mode 100644 index 000000000..ddbcc21d6 --- /dev/null +++ b/src/docx/bookmarks.py @@ -0,0 +1,64 @@ +"""Bookmark-related proxy types.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterator + +from docx.oxml.bookmarks import CT_BookmarkStart + +if TYPE_CHECKING: + from docx.oxml.document import CT_Body + + +class Bookmarks: + """Collection of |Bookmark| objects in the document.""" + + def __init__(self, body: CT_Body): + self._body = body + + def __iter__(self) -> Iterator[Bookmark]: + return ( + Bookmark(bookmarkStart, self._body) + for bookmarkStart in self._body.xpath(".//w:bookmarkStart") + ) + + def __len__(self) -> int: + return len(self._body.xpath(".//w:bookmarkStart")) + + def __contains__(self, name: object) -> bool: + if not isinstance(name, str): + return False + return self.get(name) is not None + + def get(self, name: str) -> Bookmark | None: + """Return the bookmark with `name`, or |None| if not found.""" + for bs in self._body.xpath(".//w:bookmarkStart"): + if bs.name == name: + return Bookmark(bs, self._body) + return None + + +class Bookmark: + """Proxy for a bookmark defined by a w:bookmarkStart/w:bookmarkEnd pair.""" + + def __init__(self, bookmarkStart: CT_BookmarkStart, body: CT_Body): + self._bookmarkStart = bookmarkStart + self._body = body + + @property + def name(self) -> str: + return self._bookmarkStart.name + + @property + def bookmark_id(self) -> int: + return self._bookmarkStart.id + + def delete(self) -> None: + """Remove this bookmark from the document.""" + bookmark_id = str(self._bookmarkStart.id) + # -- find and remove the matching bookmarkEnd -- + ends = self._body.xpath(f".//w:bookmarkEnd[@w:id='{bookmark_id}']") + for end in ends: + end.getparent().remove(end) + # -- remove the bookmarkStart -- + self._bookmarkStart.getparent().remove(self._bookmarkStart) diff --git a/src/docx/comments.py b/src/docx/comments.py index 8ea195224..f3981f932 100644 --- a/src/docx/comments.py +++ b/src/docx/comments.py @@ -3,7 +3,7 @@ from __future__ import annotations import datetime as dt -from typing import TYPE_CHECKING, Iterator +from typing import TYPE_CHECKING, Iterator, cast from docx.blkcntnr import BlockItemContainer @@ -97,6 +97,7 @@ class Comment(BlockItemContainer): def __init__(self, comment_elm: CT_Comment, comments_part: CommentsPart): super().__init__(comment_elm, comments_part) self._comment_elm = comment_elm + self._comments_part = comments_part def add_paragraph(self, text: str = "", style: str | ParagraphStyle | None = None) -> Paragraph: """Return paragraph newly added to the end of the content in this container. @@ -144,6 +145,50 @@ def initials(self) -> str | None: def initials(self, value: str | None): self._comment_elm.initials = value + def add_reply( + self, text: str = "", author: str = "", initials: str | None = "" + ) -> Comment: + """Add a reply to this comment and return it. + + The reply is a new comment linked to this comment via the `w16cid:paraIdParent` attribute. + Parameters behave identically to `Comments.add_comment()`. + """ + parent_para_id = self._comment_elm.paraId + if parent_para_id is None: + raise ValueError("Cannot add reply: parent comment has no paraId attribute.") + + comments_elm = cast("CT_Comments", self._comment_elm.getparent()) + reply_elm = comments_elm.add_reply(parent_para_id) + reply_elm.author = author + reply_elm.initials = initials + reply_elm.date = dt.datetime.now(dt.timezone.utc) + reply = Comment(reply_elm, self._comments_part) + + if text == "": + return reply + + para_text_iter = iter(text.split("\n")) + + first_para_text = next(para_text_iter) + first_para = reply.paragraphs[0] + first_para.add_run(first_para_text) + + for s in para_text_iter: + reply.add_paragraph(text=s) + + return reply + + @property + def replies(self) -> list[Comment]: + """List of `Comment` objects that are replies to this comment.""" + para_id = self._comment_elm.paraId + if para_id is None: + return [] + + comments_elm = cast("CT_Comments", self._comment_elm.getparent()) + reply_elms = comments_elm.get_replies_for(para_id) + return [Comment(reply_elm, self._comments_part) for reply_elm in reply_elms] + @property def text(self) -> str: """The text content of this comment as a string. diff --git a/src/docx/document.py b/src/docx/document.py index 73757b46d..9305ee5b3 100644 --- a/src/docx/document.py +++ b/src/docx/document.py @@ -9,16 +9,20 @@ from docx.blkcntnr import BlockItemContainer from docx.enum.section import WD_SECTION -from docx.enum.text import WD_BREAK +from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.section import Section, Sections from docx.shared import ElementProxy, Emu, Inches, Length from docx.text.run import Run if TYPE_CHECKING: import docx.types as t + from docx.bookmarks import Bookmarks from docx.comments import Comment, Comments + from docx.endnotes import Endnotes + from docx.footnotes import Footnotes from docx.oxml.document import CT_Body, CT_Document from docx.parts.document import DocumentPart + from docx.search import SearchMatch from docx.settings import Settings from docx.styles.style import ParagraphStyle, _TableStyle from docx.table import Table @@ -100,10 +104,10 @@ def add_heading(self, text: str = "", level: int = 1): style = "Title" if level == 0 else "Heading %d" % level return self.add_paragraph(text, style) - def add_page_break(self): + def add_page_break(self) -> Paragraph: """Return newly |Paragraph| object containing only a page break.""" paragraph = self.add_paragraph() - paragraph.add_run().add_break(WD_BREAK.PAGE) + paragraph.add_page_break() return paragraph def add_paragraph(self, text: str = "", style: str | ParagraphStyle | None = None) -> Paragraph: @@ -157,11 +161,37 @@ def add_table(self, rows: int, cols: int, style: str | _TableStyle | None = None table.style = style return table + @property + def bookmarks(self) -> Bookmarks: + """A |Bookmarks| object providing access to the bookmarks in this document.""" + from docx.bookmarks import Bookmarks + + return Bookmarks(self._element.body) + @property def comments(self) -> Comments: """A |Comments| object providing access to comments added to the document.""" return self._part.comments + @property + def endnotes(self) -> Endnotes: + """A |Endnotes| object providing access to endnotes in the document.""" + return self._part.endnotes + + @property + def has_macros(self) -> bool: + """True if this document contains a VBA project (macros).""" + try: + self._part.part_related_by(RT.VBA_PROJECT) + return True + except KeyError: + return False + + @property + def footnotes(self) -> Footnotes: + """A |Footnotes| object providing access to footnotes in the document.""" + return self._part.footnotes + @property def core_properties(self): """A |CoreProperties| object providing Dublin Core properties of document.""" @@ -195,6 +225,30 @@ def part(self) -> DocumentPart: """The |DocumentPart| object of this document.""" return self._part + def replace( + self, + old_text: str, + new_text: str, + case_sensitive: bool = True, + whole_word: bool = False, + ) -> int: + """Replace occurrences of `old_text` with `new_text` in the document body paragraphs. + + Note: Only top-level body paragraphs are searched. Text inside table cells, + headers, footers, footnotes, and endnotes is not affected. + + Preserves the run formatting of the first character's run for each replacement. + Returns the number of replacements made. + + When `case_sensitive` is False, matching is case-insensitive. When `whole_word` is + True, only whole-word matches are replaced. + """ + from docx.search import replace_in_paragraphs + + return replace_in_paragraphs( + self.paragraphs, old_text, new_text, case_sensitive, whole_word + ) + def save(self, path_or_stream: str | IO[bytes]): """Save this document to `path_or_stream`. @@ -203,6 +257,27 @@ def save(self, path_or_stream: str | IO[bytes]): """ self._part.save(path_or_stream) + def search( + self, + text: str, + case_sensitive: bool = True, + whole_word: bool = False, + ) -> List[SearchMatch]: + """Find all occurrences of `text` in the document body paragraphs. + + Note: Only top-level body paragraphs are searched. Text inside table cells, + headers, footers, footnotes, and endnotes is not included. + + Returns a list of |SearchMatch| objects, one for each occurrence found. Each match + provides access to the paragraph, run indices, and character offsets. + + When `case_sensitive` is False, matching is case-insensitive. When `whole_word` is + True, only whole-word matches are returned. + """ + from docx.search import search_paragraphs + + return search_paragraphs(self.paragraphs, text, case_sensitive, whole_word) + @property def sections(self) -> Sections: """|Sections| object providing access to each section in this document.""" diff --git a/src/docx/drawing/__init__.py b/src/docx/drawing/__init__.py index 00d1f51bb..ace0d4052 100644 --- a/src/docx/drawing/__init__.py +++ b/src/docx/drawing/__init__.py @@ -2,14 +2,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, List +from docx.enum.shape import WD_DRAWING_TYPE from docx.oxml.drawing import CT_Drawing from docx.shared import Parented if TYPE_CHECKING: import docx.types as t from docx.image.image import Image + from docx.text.paragraph import Paragraph class Drawing(Parented): @@ -57,3 +59,72 @@ def image(self) -> Image: doc_part = self.part image_part = doc_part.related_parts[rId] return image_part.image + + @property + def text(self) -> str: + """Concatenated text from all text frames in this drawing. + + Text from multiple text boxes is separated by newlines. Returns an empty + string when the drawing contains no text content (e.g. a picture). + """ + txbxContent_elements = self._drawing.txbxContent_lst + if not txbxContent_elements: + return "" + return "\n".join(txbx.text for txbx in txbxContent_elements) + + @property + def paragraphs(self) -> List[Paragraph]: + """All paragraphs inside this drawing's text frames. + + Returns an empty list when the drawing contains no text content. + """ + from docx.text.paragraph import Paragraph as ParagraphCls + + paragraphs: List[Paragraph] = [] + for txbxContent in self._drawing.txbxContent_lst: + for p in txbxContent.p_lst: + paragraphs.append(ParagraphCls(p, self._parent)) + return paragraphs + + @property + def type(self) -> WD_DRAWING_TYPE: + """The type of content in this drawing. + + Returns a member of :ref:`WD_DRAWING_TYPE` indicating whether this drawing + contains a shape, text_box, group, chart, diagram, or picture. + """ + drawing = self._drawing + + # -- check for picture first (most common) -- + if drawing.xpath( + "./wp:inline/a:graphic/a:graphicData/pic:pic" + " | ./wp:anchor/a:graphic/a:graphicData/pic:pic" + ): + return WD_DRAWING_TYPE.PICTURE + + # -- check for chart -- + if drawing.xpath( + "./wp:inline/a:graphic/a:graphicData/c:chart" + " | ./wp:anchor/a:graphic/a:graphicData/c:chart" + ): + return WD_DRAWING_TYPE.CHART + + # -- check for diagram -- + if drawing.xpath( + "./wp:inline/a:graphic/a:graphicData/dgm:*" + " | ./wp:anchor/a:graphic/a:graphicData/dgm:*" + ): + return WD_DRAWING_TYPE.DIAGRAM + + # -- check for group shape -- + if drawing.xpath( + "./wp:inline/a:graphic/a:graphicData/wpg:*" + " | ./wp:anchor/a:graphic/a:graphicData/wpg:*" + ): + return WD_DRAWING_TYPE.GROUP + + # -- check for text box (shape with txbx content) -- + if drawing.xpath(".//wps:wsp/wps:txbx/w:txbxContent"): + return WD_DRAWING_TYPE.TEXT_BOX + + return WD_DRAWING_TYPE.SHAPE diff --git a/src/docx/endnotes.py b/src/docx/endnotes.py new file mode 100644 index 000000000..f339fd65a --- /dev/null +++ b/src/docx/endnotes.py @@ -0,0 +1,134 @@ +"""Collection providing access to endnotes in this document.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterator + +from docx.blkcntnr import BlockItemContainer + +if TYPE_CHECKING: + from docx.oxml.endnotes import CT_Endnote, CT_Endnotes + from docx.parts.endnotes import EndnotesPart + from docx.styles.style import ParagraphStyle + from docx.text.paragraph import Paragraph + from docx.text.run import Run + + +class Endnotes: + """Collection containing the endnotes in this document.""" + + def __init__(self, endnotes_elm: CT_Endnotes, endnotes_part: EndnotesPart): + self._endnotes_elm = endnotes_elm + self._endnotes_part = endnotes_part + + def __iter__(self) -> Iterator[Endnote]: + return ( + Endnote(endnote_elm, self._endnotes_part) + for endnote_elm in self._endnotes_elm.endnote_lst + if endnote_elm.type is None + ) + + def __len__(self) -> int: + return sum(1 for en in self._endnotes_elm.endnote_lst if en.type is None) + + def add(self, run: Run, text: str = "") -> Endnote: + """Add a new endnote referenced from `run` and return it. + + A `w:endnoteReference` element is inserted into `run`, styled with the + "EndnoteReference" character style. The new endnote contains a single paragraph + with the "EndnoteText" style. If `text` is provided, it is added as a run in that + paragraph following the endnote reference mark. + """ + endnote_elm = self._endnotes_elm.add_endnote() + endnote = Endnote(endnote_elm, self._endnotes_part) + + # -- insert endnoteReference into the specified run in the document body -- + run._r.insert_endnote_reference(endnote_elm.id) # pyright: ignore[reportPrivateUsage] + + # -- add text to the first paragraph if provided -- + if text: + first_para = endnote.paragraphs[0] + first_para.add_run(text) + + return endnote + + +class Endnote(BlockItemContainer): + """Proxy for a single endnote in the document. + + An endnote is a block-item container, similar to a table cell, so it can contain both + paragraphs and tables. + """ + + def __init__(self, endnote_elm: CT_Endnote, endnotes_part: EndnotesPart): + super().__init__(endnote_elm, endnotes_part) + self._endnote_elm = endnote_elm + + def clear(self) -> Endnote: + """Remove all content from this endnote, leaving a single empty paragraph. + + The empty paragraph has the "EndnoteText" style. Returns this same endnote + object for fluent use. + """ + self._endnote_elm.clear_content() + return self + + def delete(self) -> None: + """Remove this endnote from the document. + + Removes the `w:endnoteReference` element from the document body that references + this endnote, along with the run containing it (if the run becomes empty). Also + removes the `w:endnote` element from the endnotes part. + + After calling this method, this |Endnote| object is "defunct" and should not be + used further. + """ + endnote_id = self.endnote_id + # -- remove endnoteReference(s) from the document body -- + document_elm = self.part._document_part.element # pyright: ignore[reportPrivateUsage] + refs = document_elm.xpath( + f'.//w:endnoteReference[@w:id="{endnote_id}"]', + ) + for ref in refs: + r = ref.getparent() + if r is None: + continue + r.remove(ref) + # -- remove the run if it's now empty (only rPr or nothing left) -- + if len(r.xpath("./*[not(self::w:rPr)]")) == 0: + r_parent = r.getparent() + if r_parent is not None: + r_parent.remove(r) + # -- remove the endnote element from the endnotes part -- + endnotes_elm = self._endnote_elm.getparent() + if endnotes_elm is not None: + endnotes_elm.remove(self._endnote_elm) + + def add_paragraph(self, text: str = "", style: str | ParagraphStyle | None = None) -> Paragraph: + """Return paragraph newly added to the end of the content in this container. + + The paragraph has `text` in a single run if present, and is given paragraph style `style`. + When `style` is |None| or omitted, the "EndnoteText" paragraph style is applied, which is + the default style for endnotes. + """ + paragraph = super().add_paragraph(text, style) + + if style is None: + paragraph._p.style = "EndnoteText" # pyright: ignore[reportPrivateUsage] + + return paragraph + + @property + def endnote_id(self) -> int: + """The unique identifier of this endnote.""" + return self._endnote_elm.id + + @property + def text(self) -> str: + """The text content of this endnote as a string. + + Only content in paragraphs is included and all emphasis and styling is stripped. + + Paragraph boundaries are indicated with a newline (`"\\n"`). + """ + return "\n".join(p.text for p in self.paragraphs) diff --git a/src/docx/enum/shape.py b/src/docx/enum/shape.py index ed086c38d..ad3e1b4ca 100644 --- a/src/docx/enum/shape.py +++ b/src/docx/enum/shape.py @@ -17,3 +17,14 @@ class WD_INLINE_SHAPE_TYPE(enum.Enum): WD_INLINE_SHAPE = WD_INLINE_SHAPE_TYPE + + +class WD_DRAWING_TYPE(enum.Enum): + """Type of content contained in a `` element.""" + + SHAPE = 1 + TEXT_BOX = 2 + GROUP = 3 + CHART = 4 + DIAGRAM = 5 + PICTURE = 6 diff --git a/src/docx/enum/table.py b/src/docx/enum/table.py index eb1eb9dc0..b13adab84 100644 --- a/src/docx/enum/table.py +++ b/src/docx/enum/table.py @@ -107,6 +107,243 @@ class WD_TABLE_ALIGNMENT(BaseXmlEnum): """Right-aligned.""" +class WD_SHADING_PATTERN(BaseXmlEnum): + """Specifies the pattern style for cell shading. + + Example:: + + from docx.enum.table import WD_SHADING_PATTERN + + table = document.add_table(3, 3) + cell = table.cell(0, 0) + cell.shading.pattern = WD_SHADING_PATTERN.CLEAR + + MS API name: `WdShadingPattern` (partial) + """ + + CLEAR = (0, "clear", "No pattern, just background fill color.") + """No pattern, just background fill color.""" + + SOLID = (1, "solid", "Solid pattern (foreground color fills entire area).") + """Solid pattern (foreground color fills entire area).""" + + HORZ_STRIPE = (2, "horzStripe", "Horizontal stripe pattern.") + """Horizontal stripe pattern.""" + + VERT_STRIPE = (3, "vertStripe", "Vertical stripe pattern.") + """Vertical stripe pattern.""" + + REVERSE_DIAG_STRIPE = (4, "reverseDiagStripe", "Reverse diagonal stripe pattern.") + """Reverse diagonal stripe pattern.""" + + DIAG_STRIPE = (5, "diagStripe", "Diagonal stripe pattern.") + """Diagonal stripe pattern.""" + + HORZ_CROSS = (6, "horzCross", "Horizontal cross pattern.") + """Horizontal cross pattern.""" + + DIAG_CROSS = (7, "diagCross", "Diagonal cross pattern.") + """Diagonal cross pattern.""" + + THIN_HORZ_STRIPE = (8, "thinHorzStripe", "Thin horizontal stripe pattern.") + """Thin horizontal stripe pattern.""" + + THIN_VERT_STRIPE = (9, "thinVertStripe", "Thin vertical stripe pattern.") + """Thin vertical stripe pattern.""" + + THIN_REVERSE_DIAG_STRIPE = ( + 10, + "thinReverseDiagStripe", + "Thin reverse diagonal stripe pattern.", + ) + """Thin reverse diagonal stripe pattern.""" + + THIN_DIAG_STRIPE = (11, "thinDiagStripe", "Thin diagonal stripe pattern.") + """Thin diagonal stripe pattern.""" + + THIN_HORZ_CROSS = (12, "thinHorzCross", "Thin horizontal cross pattern.") + """Thin horizontal cross pattern.""" + + THIN_DIAG_CROSS = (13, "thinDiagCross", "Thin diagonal cross pattern.") + """Thin diagonal cross pattern.""" + + PCT_5 = (14, "pct5", "5 percent fill pattern.") + """5 percent fill pattern.""" + + PCT_10 = (15, "pct10", "10 percent fill pattern.") + """10 percent fill pattern.""" + + PCT_12 = (16, "pct12", "12.5 percent fill pattern.") + """12.5 percent fill pattern.""" + + PCT_15 = (17, "pct15", "15 percent fill pattern.") + """15 percent fill pattern.""" + + PCT_20 = (18, "pct20", "20 percent fill pattern.") + """20 percent fill pattern.""" + + PCT_25 = (19, "pct25", "25 percent fill pattern.") + """25 percent fill pattern.""" + + PCT_30 = (20, "pct30", "30 percent fill pattern.") + """30 percent fill pattern.""" + + PCT_35 = (21, "pct35", "35 percent fill pattern.") + """35 percent fill pattern.""" + + PCT_37 = (22, "pct37", "37.5 percent fill pattern.") + """37.5 percent fill pattern.""" + + PCT_40 = (23, "pct40", "40 percent fill pattern.") + """40 percent fill pattern.""" + + PCT_45 = (24, "pct45", "45 percent fill pattern.") + """45 percent fill pattern.""" + + PCT_50 = (25, "pct50", "50 percent fill pattern.") + """50 percent fill pattern.""" + + PCT_55 = (26, "pct55", "55 percent fill pattern.") + """55 percent fill pattern.""" + + PCT_60 = (27, "pct60", "60 percent fill pattern.") + """60 percent fill pattern.""" + + PCT_62 = (28, "pct62", "62.5 percent fill pattern.") + """62.5 percent fill pattern.""" + + PCT_65 = (29, "pct65", "65 percent fill pattern.") + """65 percent fill pattern.""" + + PCT_70 = (30, "pct70", "70 percent fill pattern.") + """70 percent fill pattern.""" + + PCT_75 = (31, "pct75", "75 percent fill pattern.") + """75 percent fill pattern.""" + + PCT_80 = (32, "pct80", "80 percent fill pattern.") + """80 percent fill pattern.""" + + PCT_85 = (33, "pct85", "85 percent fill pattern.") + """85 percent fill pattern.""" + + PCT_87 = (34, "pct87", "87.5 percent fill pattern.") + """87.5 percent fill pattern.""" + + PCT_90 = (35, "pct90", "90 percent fill pattern.") + """90 percent fill pattern.""" + + PCT_95 = (36, "pct95", "95 percent fill pattern.") + """95 percent fill pattern.""" + + NIL = (37, "nil", "No shading.") + """No shading.""" + + +class WD_BORDER_STYLE(BaseXmlEnum): + """Specifies the style of a table or cell border. + + Example:: + + from docx.enum.table import WD_BORDER_STYLE + + table = document.add_table(3, 3) + table.borders.top.style = WD_BORDER_STYLE.SINGLE + + Based on the ST_Border simple type in the Open XML spec. + """ + + NONE = (0, "none", "No border.") + """No border.""" + + SINGLE = (1, "single", "A single line.") + """A single line.""" + + DOUBLE = (2, "double", "A double line.") + """A double line.""" + + DOTTED = (3, "dotted", "A dotted line.") + """A dotted line.""" + + DASHED = (4, "dashed", "A dashed line.") + """A dashed line.""" + + DOT_DASH = (5, "dotDash", "A line with alternating dots and dashes.") + """A line with alternating dots and dashes.""" + + DOT_DOT_DASH = (6, "dotDotDash", "A line with a repeating dot-dot-dash pattern.") + """A line with a repeating dot-dot-dash pattern.""" + + TRIPLE = (7, "triple", "A triple line.") + """A triple line.""" + + THIN_THICK_SMALL_GAP = (8, "thinThickSmallGap", "A thin-thick line with a small gap.") + """A thin-thick line with a small gap.""" + + THICK_THIN_SMALL_GAP = (9, "thickThinSmallGap", "A thick-thin line with a small gap.") + """A thick-thin line with a small gap.""" + + THIN_THICK_THIN_SMALL_GAP = ( + 10, + "thinThickThinSmallGap", + "A thin-thick-thin line with a small gap.", + ) + """A thin-thick-thin line with a small gap.""" + + THIN_THICK_MEDIUM_GAP = (11, "thinThickMediumGap", "A thin-thick line with a medium gap.") + """A thin-thick line with a medium gap.""" + + THICK_THIN_MEDIUM_GAP = (12, "thickThinMediumGap", "A thick-thin line with a medium gap.") + """A thick-thin line with a medium gap.""" + + THIN_THICK_THIN_MEDIUM_GAP = ( + 13, + "thinThickThinMediumGap", + "A thin-thick-thin line with a medium gap.", + ) + """A thin-thick-thin line with a medium gap.""" + + THIN_THICK_LARGE_GAP = (14, "thinThickLargeGap", "A thin-thick line with a large gap.") + """A thin-thick line with a large gap.""" + + THICK_THIN_LARGE_GAP = (15, "thickThinLargeGap", "A thick-thin line with a large gap.") + """A thick-thin line with a large gap.""" + + THIN_THICK_THIN_LARGE_GAP = ( + 16, + "thinThickThinLargeGap", + "A thin-thick-thin line with a large gap.", + ) + """A thin-thick-thin line with a large gap.""" + + WAVE = (17, "wave", "A wavy line.") + """A wavy line.""" + + DOUBLE_WAVE = (18, "doubleWave", "A double wavy line.") + """A double wavy line.""" + + DASH_SMALL_GAP = (19, "dashSmallGap", "A dashed line with small gaps.") + """A dashed line with small gaps.""" + + DASH_DOT_STROKED = (20, "dashDotStroked", "A dash-dot stroked line.") + """A dash-dot stroked line.""" + + THREE_D_EMBOSS = (21, "threeDEmboss", "A 3D embossed line.") + """A 3D embossed line.""" + + THREE_D_ENGRAVE = (22, "threeDEngrave", "A 3D engraved line.") + """A 3D engraved line.""" + + OUTSET = (23, "outset", "An outset line.") + """An outset line.""" + + INSET = (24, "inset", "An inset line.") + """An inset line.""" + + NIL = (25, "nil", "No border (used to override inherited border).") + """No border (used to override inherited border).""" + + class WD_TABLE_DIRECTION(BaseEnum): """Specifies the direction in which an application orders cells in the specified table or row. diff --git a/src/docx/enum/text.py b/src/docx/enum/text.py index 99e776fea..ab461cd76 100644 --- a/src/docx/enum/text.py +++ b/src/docx/enum/text.py @@ -271,6 +271,112 @@ class WD_TAB_LEADER(BaseXmlEnum): """A vertically-centered dot.""" +class WD_BORDER_STYLE(BaseXmlEnum): + """Specifies the style of a paragraph border. + + Example:: + + from docx.enum.text import WD_BORDER_STYLE + + paragraph = document.add_paragraph() + paragraph.paragraph_format.borders.bottom.style = WD_BORDER_STYLE.SINGLE + """ + + NIL = (0, "nil", "No border.") + """No border.""" + + NONE = (1, "none", "No border.") + """No border.""" + + SINGLE = (2, "single", "A single line.") + """A single line.""" + + THICK = (3, "thick", "A single thick line.") + """A single thick line.""" + + DOUBLE = (4, "double", "A double line.") + """A double line.""" + + DOTTED = (5, "dotted", "A dotted line.") + """A dotted line.""" + + DASHED = (6, "dashed", "A dashed line.") + """A dashed line.""" + + DOT_DASH = (7, "dotDash", "An alternating dot-dash line.") + """An alternating dot-dash line.""" + + DOT_DOT_DASH = (8, "dotDotDash", "An alternating dot-dot-dash line.") + """An alternating dot-dot-dash line.""" + + TRIPLE = (9, "triple", "A triple line.") + """A triple line.""" + + THIN_THICK_SMALL_GAP = (10, "thinThickSmallGap", "A thin-thick line with a small gap.") + """A thin-thick line with a small gap.""" + + THICK_THIN_SMALL_GAP = (11, "thickThinSmallGap", "A thick-thin line with a small gap.") + """A thick-thin line with a small gap.""" + + THIN_THICK_THIN_SMALL_GAP = ( + 12, + "thinThickThinSmallGap", + "A thin-thick-thin line with a small gap.", + ) + """A thin-thick-thin line with a small gap.""" + + THIN_THICK_MEDIUM_GAP = (13, "thinThickMediumGap", "A thin-thick line with a medium gap.") + """A thin-thick line with a medium gap.""" + + THICK_THIN_MEDIUM_GAP = (14, "thickThinMediumGap", "A thick-thin line with a medium gap.") + """A thick-thin line with a medium gap.""" + + THIN_THICK_THIN_MEDIUM_GAP = ( + 15, + "thinThickThinMediumGap", + "A thin-thick-thin line with a medium gap.", + ) + """A thin-thick-thin line with a medium gap.""" + + THIN_THICK_LARGE_GAP = (16, "thinThickLargeGap", "A thin-thick line with a large gap.") + """A thin-thick line with a large gap.""" + + THICK_THIN_LARGE_GAP = (17, "thickThinLargeGap", "A thick-thin line with a large gap.") + """A thick-thin line with a large gap.""" + + THIN_THICK_THIN_LARGE_GAP = ( + 18, + "thinThickThinLargeGap", + "A thin-thick-thin line with a large gap.", + ) + """A thin-thick-thin line with a large gap.""" + + WAVE = (19, "wave", "A wavy line.") + """A wavy line.""" + + DOUBLE_WAVE = (20, "doubleWave", "A double wavy line.") + """A double wavy line.""" + + DASH_SMALL_GAP = (21, "dashSmallGap", "A dashed line with a small gap.") + """A dashed line with a small gap.""" + + DASH_DOT_STROKED = (22, "dashDotStroked", "A dash-dot stroked line.") + """A dash-dot stroked line.""" + + THREE_D_EMBOSS = (23, "threeDEmboss", "A 3D embossed line.") + """A 3D embossed line.""" + + THREE_D_ENGRAVE = (24, "threeDEngrave", "A 3D engraved line.") + """A 3D engraved line.""" + + OUTSET = (25, "outset", "An outset line.") + """An outset line.""" + + INSET = (26, "inset", "An inset line.") + """An inset line.""" + + + class WD_UNDERLINE(BaseXmlEnum): """Specifies the style of underline applied to a run of characters. diff --git a/src/docx/footnotes.py b/src/docx/footnotes.py new file mode 100644 index 000000000..38e94ded8 --- /dev/null +++ b/src/docx/footnotes.py @@ -0,0 +1,134 @@ +"""Collection providing access to footnotes in this document.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterator + +from docx.blkcntnr import BlockItemContainer + +if TYPE_CHECKING: + from docx.oxml.footnotes import CT_Footnote, CT_Footnotes + from docx.parts.footnotes import FootnotesPart + from docx.styles.style import ParagraphStyle + from docx.text.paragraph import Paragraph + from docx.text.run import Run + + +class Footnotes: + """Collection containing the footnotes in this document.""" + + def __init__(self, footnotes_elm: CT_Footnotes, footnotes_part: FootnotesPart): + self._footnotes_elm = footnotes_elm + self._footnotes_part = footnotes_part + + def __iter__(self) -> Iterator[Footnote]: + return ( + Footnote(footnote_elm, self._footnotes_part) + for footnote_elm in self._footnotes_elm.footnote_lst + if footnote_elm.type is None + ) + + def __len__(self) -> int: + return sum(1 for fn in self._footnotes_elm.footnote_lst if fn.type is None) + + def add(self, run: Run, text: str = "") -> Footnote: + """Add a new footnote referenced from `run` and return it. + + A `w:footnoteReference` element is inserted into `run`, styled with the + "FootnoteReference" character style. The new footnote contains a single paragraph + with the "FootnoteText" style. If `text` is provided, it is added as a run in that + paragraph following the footnote reference mark. + """ + footnote_elm = self._footnotes_elm.add_footnote() + footnote = Footnote(footnote_elm, self._footnotes_part) + + # -- insert footnoteReference into the specified run in the document body -- + run._r.insert_footnote_reference(footnote_elm.id) # pyright: ignore[reportPrivateUsage] + + # -- add text to the first paragraph if provided -- + if text: + first_para = footnote.paragraphs[0] + first_para.add_run(text) + + return footnote + + +class Footnote(BlockItemContainer): + """Proxy for a single footnote in the document. + + A footnote is a block-item container, similar to a table cell, so it can contain both + paragraphs and tables. + """ + + def __init__(self, footnote_elm: CT_Footnote, footnotes_part: FootnotesPart): + super().__init__(footnote_elm, footnotes_part) + self._footnote_elm = footnote_elm + + def clear(self) -> Footnote: + """Remove all content from this footnote, leaving a single empty paragraph. + + The empty paragraph has the "FootnoteText" style. Returns this same footnote + object for fluent use. + """ + self._footnote_elm.clear_content() + return self + + def delete(self) -> None: + """Remove this footnote from the document. + + Removes the `w:footnoteReference` element from the document body that references + this footnote, along with the run containing it (if the run becomes empty). Also + removes the `w:footnote` element from the footnotes part. + + After calling this method, this |Footnote| object is "defunct" and should not be + used further. + """ + footnote_id = self.footnote_id + # -- remove footnoteReference(s) from the document body -- + document_elm = self.part._document_part.element # pyright: ignore[reportPrivateUsage] + refs = document_elm.xpath( + f'.//w:footnoteReference[@w:id="{footnote_id}"]', + ) + for ref in refs: + r = ref.getparent() + if r is None: + continue + r.remove(ref) + # -- remove the run if it's now empty (only rPr or nothing left) -- + if len(r.xpath("./*[not(self::w:rPr)]")) == 0: + r_parent = r.getparent() + if r_parent is not None: + r_parent.remove(r) + # -- remove the footnote element from the footnotes part -- + footnotes_elm = self._footnote_elm.getparent() + if footnotes_elm is not None: + footnotes_elm.remove(self._footnote_elm) + + def add_paragraph(self, text: str = "", style: str | ParagraphStyle | None = None) -> Paragraph: + """Return paragraph newly added to the end of the content in this container. + + The paragraph has `text` in a single run if present, and is given paragraph style `style`. + When `style` is |None| or omitted, the "FootnoteText" paragraph style is applied, which is + the default style for footnotes. + """ + paragraph = super().add_paragraph(text, style) + + if style is None: + paragraph._p.style = "FootnoteText" # pyright: ignore[reportPrivateUsage] + + return paragraph + + @property + def footnote_id(self) -> int: + """The unique identifier of this footnote.""" + return self._footnote_elm.id + + @property + def text(self) -> str: + """The text content of this footnote as a string. + + Only content in paragraphs is included and all emphasis and styling is stripped. + + Paragraph boundaries are indicated with a newline (`"\\n"`). + """ + return "\n".join(p.text for p in self.paragraphs) diff --git a/src/docx/image/constants.py b/src/docx/image/constants.py index 03fae5855..ec78ff25f 100644 --- a/src/docx/image/constants.py +++ b/src/docx/image/constants.py @@ -104,6 +104,7 @@ class MIME_TYPE: GIF = "image/gif" JPEG = "image/jpeg" PNG = "image/png" + SVG = "image/svg+xml" TIFF = "image/tiff" diff --git a/src/docx/image/image.py b/src/docx/image/image.py index e5e7f8a13..22f373d43 100644 --- a/src/docx/image/image.py +++ b/src/docx/image/image.py @@ -179,6 +179,13 @@ def read_32(stream: IO[bytes]): found_bytes = header[offset:end] if found_bytes == signature_bytes: return cls.from_stream(stream) + + # SVG is text-based XML, so check for it after binary signature matching fails + from docx.image.svg import Svg, is_svg_stream + + if is_svg_stream(stream): + return Svg.from_stream(stream) + raise UnrecognizedImageError diff --git a/src/docx/image/svg.py b/src/docx/image/svg.py new file mode 100644 index 000000000..635ea5d2e --- /dev/null +++ b/src/docx/image/svg.py @@ -0,0 +1,125 @@ +"""SVG image header parser.""" + +from __future__ import annotations + +import re +import struct +import zlib +from typing import IO, Tuple + +from docx.image.constants import MIME_TYPE +from docx.image.image import BaseImageHeader + + +class Svg(BaseImageHeader): + """Image header parser for SVG images.""" + + @property + def content_type(self) -> str: + return MIME_TYPE.SVG + + @property + def default_ext(self) -> str: + return "svg" + + @classmethod + def from_stream(cls, stream: IO[bytes]) -> Svg: + stream.seek(0) + data = stream.read() + try: + text = data.decode("utf-8") + except UnicodeDecodeError: + return cls(300, 150, 96, 96) + px_width, px_height = cls._parse_dimensions(text) + # SVG uses 96 DPI (CSS reference pixel) + return cls(px_width, px_height, 96, 96) + + @classmethod + def _parse_dimensions(cls, svg_text: str) -> Tuple[int, int]: + import defusedxml.ElementTree as SafeET + + try: + root = SafeET.fromstring(svg_text) + except Exception: + return 300, 150 # default SVG dimensions per spec + + # Check for width/height attributes + width_str = root.get("width", "") + height_str = root.get("height", "") + + width = cls._parse_length(width_str) + height = cls._parse_length(height_str) + + if width and height: + return width, height + + # Fall back to viewBox + viewbox = root.get("viewBox", "") + if viewbox: + parts = re.split(r"[\s,]+", viewbox.strip()) + if len(parts) == 4: + try: + vb_width = float(parts[2]) + vb_height = float(parts[3]) + if vb_width > 0 and vb_height > 0: + return int(round(vb_width)), int(round(vb_height)) + except ValueError: + pass + + return 300, 150 + + @classmethod + def _parse_length(cls, length_str: str | None) -> int | None: + if not length_str: + return None + + match = re.match(r"^\s*([\d.]+)\s*(px|pt|in|cm|mm|)\s*$", length_str) + if not match: + return None + + value = float(match.group(1)) + unit = match.group(2) + + if unit in ("", "px"): + return int(round(value)) + elif unit == "pt": + return int(round(value * 96 / 72)) + elif unit == "in": + return int(round(value * 96)) + elif unit == "cm": + return int(round(value * 96 / 2.54)) + elif unit == "mm": + return int(round(value * 96 / 25.4)) + + +def is_svg_stream(stream: IO[bytes]) -> bool: + """Return True if `stream` contains an SVG image.""" + stream.seek(0) + header = stream.read(4096) + stripped = header.lstrip() + # Strip BOM if present + if stripped.startswith(b"\xef\xbb\xbf"): + stripped = stripped[3:].lstrip() + return stripped.startswith(b" bytes: + """Generate a minimal 1x1 transparent PNG for SVG fallback.""" + width, height = 1, 1 + ihdr_data = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) + raw = b"\x00" + b"\x00\x00\x00\x00" + compressed = zlib.compress(raw) + + def chunk(type_code: bytes, data: bytes) -> bytes: + c = type_code + data + crc = struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF) + return struct.pack(">I", len(data)) + c + crc + + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", ihdr_data) + + chunk(b"IDAT", compressed) + + chunk(b"IEND", b"") + ) diff --git a/src/docx/opc/constants.py b/src/docx/opc/constants.py index a3d0e0812..0f351a781 100644 --- a/src/docx/opc/constants.py +++ b/src/docx/opc/constants.py @@ -126,12 +126,14 @@ class CONTENT_TYPE: "application/vnd.openxmlformats-officedocument.spreadsheetml.volatileDependencies+xml" ) SML_WORKSHEET = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" + SVG = "image/svg+xml" TIFF = "image/tiff" WML_COMMENTS = "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml" WML_DOCUMENT = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" WML_DOCUMENT_GLOSSARY = ( "application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml" ) + WML_DOCUMENT_MACRO = "application/vnd.ms-word.document.macroEnabled.main+xml" WML_DOCUMENT_MAIN = ( "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml" ) @@ -146,6 +148,7 @@ class CONTENT_TYPE: ) WML_SETTINGS = "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml" WML_STYLES = "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml" + WML_VBA_PROJECT = "application/vnd.ms-office.vbaProject" WML_WEB_SETTINGS = ( "application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml" ) @@ -293,6 +296,7 @@ class RELATIONSHIP_TYPE: ) THUMBNAIL = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail" USERNAMES = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/usernames" + VBA_PROJECT = "http://schemas.microsoft.com/office/2006/relationships/vbaProject" VIDEO = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/video" VIEW_PROPS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/viewProps" VML_DRAWING = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" diff --git a/src/docx/opc/oxml.py b/src/docx/opc/oxml.py index 7d3c489d6..2d453ddbd 100644 --- a/src/docx/opc/oxml.py +++ b/src/docx/opc/oxml.py @@ -17,8 +17,15 @@ from docx.opc.constants import RELATIONSHIP_TARGET_MODE as RTM # configure XML parser +# Security: resolve_entities=False prevents XXE attacks, no_network=True prevents +# network access during parsing, huge_tree=False prevents XML bombs (billion laughs). element_class_lookup = etree.ElementNamespaceClassLookup() -oxml_parser = etree.XMLParser(remove_blank_text=True, resolve_entities=False) +oxml_parser = etree.XMLParser( + remove_blank_text=True, + resolve_entities=False, + no_network=True, + huge_tree=False, +) oxml_parser.set_element_class_lookup(element_class_lookup) nsmap = { diff --git a/src/docx/opc/phys_pkg.py b/src/docx/opc/phys_pkg.py index 5ec32237c..5155b7adf 100644 --- a/src/docx/opc/phys_pkg.py +++ b/src/docx/opc/phys_pkg.py @@ -44,6 +44,13 @@ def __init__(self, path): def blob_for(self, pack_uri): """Return contents of file corresponding to `pack_uri` in package directory.""" path = os.path.join(self._path, pack_uri.membername) + # Guard against path traversal — resolved path must remain within package dir + real_path = os.path.realpath(path) + real_root = os.path.realpath(self._path) + if not real_path.startswith(real_root + os.sep) and real_path != real_root: + raise ValueError( + "Pack URI '%s' resolves outside package directory" % pack_uri + ) with open(path, "rb") as f: blob = f.read() return blob diff --git a/src/docx/oxml/__init__.py b/src/docx/oxml/__init__.py index 37f608cef..4ba66887b 100644 --- a/src/docx/oxml/__init__.py +++ b/src/docx/oxml/__init__.py @@ -7,7 +7,7 @@ from __future__ import annotations -from docx.oxml.drawing import CT_Drawing +from docx.oxml.drawing import CT_Drawing, CT_TextBox, CT_TxbxContent, CT_WordprocessingShape from docx.oxml.parser import OxmlElement, parse_xml, register_element_cls from docx.oxml.shape import ( CT_Anchor, @@ -56,10 +56,13 @@ register_element_cls("pic:pic", CT_Picture) register_element_cls("pic:spPr", CT_ShapeProperties) register_element_cls("w:drawing", CT_Drawing) +register_element_cls("w:txbxContent", CT_TxbxContent) register_element_cls("wp:anchor", CT_Anchor) register_element_cls("wp:docPr", CT_NonVisualDrawingProps) register_element_cls("wp:extent", CT_PositiveSize2D) register_element_cls("wp:inline", CT_Inline) +register_element_cls("wps:txbx", CT_TextBox) +register_element_cls("wps:wsp", CT_WordprocessingShape) # --------------------------------------------------------------------------- # hyperlink-related elements @@ -86,6 +89,11 @@ # --------------------------------------------------------------------------- # other custom element class mappings +from .bookmarks import CT_BookmarkEnd, CT_BookmarkStart + +register_element_cls("w:bookmarkEnd", CT_BookmarkEnd) +register_element_cls("w:bookmarkStart", CT_BookmarkStart) + from .comments import CT_Comments, CT_Comment register_element_cls("w:comments", CT_Comments) @@ -95,11 +103,21 @@ register_element_cls("cp:coreProperties", CT_CoreProperties) +from .endnotes import CT_Endnote, CT_Endnotes + +register_element_cls("w:endnote", CT_Endnote) +register_element_cls("w:endnotes", CT_Endnotes) + from .document import CT_Body, CT_Document register_element_cls("w:body", CT_Body) register_element_cls("w:document", CT_Document) +from .footnotes import CT_Footnote, CT_Footnotes + +register_element_cls("w:footnote", CT_Footnote) +register_element_cls("w:footnotes", CT_Footnotes) + from .numbering import CT_Num, CT_Numbering, CT_NumLvl, CT_NumPr register_element_cls("w:abstractNumId", CT_DecimalNumber) @@ -112,6 +130,8 @@ register_element_cls("w:startOverride", CT_DecimalNumber) from .section import ( + CT_Col, + CT_Cols, CT_HdrFtr, CT_HdrFtrRef, CT_PageMar, @@ -120,6 +140,8 @@ CT_SectType, ) +register_element_cls("w:col", CT_Col) +register_element_cls("w:cols", CT_Cols) register_element_cls("w:footerReference", CT_HdrFtrRef) register_element_cls("w:ftr", CT_HdrFtr) register_element_cls("w:hdr", CT_HdrFtr) @@ -129,9 +151,15 @@ register_element_cls("w:sectPr", CT_SectPr) register_element_cls("w:type", CT_SectType) -from .settings import CT_Settings +from .settings import CT_Compat, CT_CompatSetting, CT_DefaultTabStop, CT_DocProtect, CT_Settings, CT_Zoom +register_element_cls("w:compat", CT_Compat) +register_element_cls("w:compatSetting", CT_CompatSetting) +register_element_cls("w:defaultTabStop", CT_DefaultTabStop) +register_element_cls("w:documentProtection", CT_DocProtect) register_element_cls("w:settings", CT_Settings) +register_element_cls("w:trackRevisions", CT_OnOff) +register_element_cls("w:zoom", CT_Zoom) from .styles import CT_LatentStyles, CT_LsdException, CT_Style, CT_Styles @@ -149,9 +177,12 @@ register_element_cls("w:unhideWhenUsed", CT_OnOff) from .table import ( + CT_Border, CT_Height, CT_Row, + CT_Shd, CT_Tbl, + CT_TblBorders, CT_TblGrid, CT_TblGridCol, CT_TblLayoutType, @@ -159,6 +190,7 @@ CT_TblPrEx, CT_TblWidth, CT_Tc, + CT_TcBorders, CT_TcPr, CT_TrPr, CT_VMerge, @@ -166,12 +198,23 @@ ) register_element_cls("w:bidiVisual", CT_OnOff) +register_element_cls("w:tblBorders", CT_TblBorders) +register_element_cls("w:tcBorders", CT_TcBorders) +register_element_cls("w:top", CT_Border) +register_element_cls("w:left", CT_Border) +register_element_cls("w:bottom", CT_Border) +register_element_cls("w:right", CT_Border) +register_element_cls("w:insideH", CT_Border) +register_element_cls("w:insideV", CT_Border) +register_element_cls("w:cantSplit", CT_OnOff) register_element_cls("w:gridAfter", CT_DecimalNumber) register_element_cls("w:gridBefore", CT_DecimalNumber) register_element_cls("w:gridCol", CT_TblGridCol) register_element_cls("w:gridSpan", CT_DecimalNumber) +register_element_cls("w:shd", CT_Shd) register_element_cls("w:tbl", CT_Tbl) register_element_cls("w:tblGrid", CT_TblGrid) +register_element_cls("w:tblHeader", CT_OnOff) register_element_cls("w:tblLayout", CT_TblLayoutType) register_element_cls("w:tblPr", CT_TblPr) register_element_cls("w:tblPrEx", CT_TblPrEx) @@ -206,6 +249,7 @@ register_element_cls("w:i", CT_OnOff) register_element_cls("w:iCs", CT_OnOff) register_element_cls("w:imprint", CT_OnOff) +register_element_cls("w:kern", CT_HpsMeasure) register_element_cls("w:noProof", CT_OnOff) register_element_cls("w:oMath", CT_OnOff) register_element_cls("w:outline", CT_OnOff) @@ -228,24 +272,53 @@ register_element_cls("w:p", CT_P) +from .tracked_changes import CT_Del, CT_DelText, CT_Ins + +register_element_cls("w:del", CT_Del) +register_element_cls("w:delText", CT_DelText) +register_element_cls("w:ins", CT_Ins) + from .text.parfmt import ( + CT_Border, CT_Ind, CT_Jc, + CT_PBdr, CT_PPr, CT_Spacing, CT_TabStop, CT_TabStops, ) +register_element_cls("w:bar", CT_Border) +register_element_cls("w:between", CT_Border) +register_element_cls("w:bottom", CT_Border) register_element_cls("w:ind", CT_Ind) register_element_cls("w:jc", CT_Jc) register_element_cls("w:keepLines", CT_OnOff) register_element_cls("w:keepNext", CT_OnOff) register_element_cls("w:outlineLvl", CT_DecimalNumber) register_element_cls("w:pageBreakBefore", CT_OnOff) +register_element_cls("w:left", CT_Border) +register_element_cls("w:pBdr", CT_PBdr) register_element_cls("w:pPr", CT_PPr) register_element_cls("w:pStyle", CT_String) +register_element_cls("w:right", CT_Border) register_element_cls("w:spacing", CT_Spacing) register_element_cls("w:tab", CT_TabStop) register_element_cls("w:tabs", CT_TabStops) +register_element_cls("w:top", CT_Border) register_element_cls("w:widowControl", CT_OnOff) + +# --------------------------------------------------------------------------- +# Annotation reference elements — used in comments/footnotes markup but do not +# need custom behaviour beyond what BaseOxmlElement provides. Registering them +# ensures they are recognised by the parser's element-class lookup. + +from docx.oxml.xmlchemy import BaseOxmlElement as _Base + +register_element_cls("w:annotationRef", _Base) +register_element_cls("w:commentRangeEnd", _Base) +register_element_cls("w:commentRangeStart", _Base) +register_element_cls("w:commentReference", _Base) +register_element_cls("w:footnoteRef", _Base) +register_element_cls("w:footnoteReference", _Base) diff --git a/src/docx/oxml/bookmarks.py b/src/docx/oxml/bookmarks.py new file mode 100644 index 000000000..ea9a025f5 --- /dev/null +++ b/src/docx/oxml/bookmarks.py @@ -0,0 +1,19 @@ +"""Custom element classes related to bookmarks.""" + +from __future__ import annotations + +from docx.oxml.simpletypes import ST_DecimalNumber, ST_String +from docx.oxml.xmlchemy import BaseOxmlElement, RequiredAttribute + + +class CT_BookmarkStart(BaseOxmlElement): + """`w:bookmarkStart` element, marking the start of a bookmarked range.""" + + id: int = RequiredAttribute("w:id", ST_DecimalNumber) # pyright: ignore[reportAssignmentType] + name: str = RequiredAttribute("w:name", ST_String) # pyright: ignore[reportAssignmentType] + + +class CT_BookmarkEnd(BaseOxmlElement): + """`w:bookmarkEnd` element, marking the end of a bookmarked range.""" + + id: int = RequiredAttribute("w:id", ST_DecimalNumber) # pyright: ignore[reportAssignmentType] diff --git a/src/docx/oxml/comments.py b/src/docx/oxml/comments.py index ad9821759..5c2ca482a 100644 --- a/src/docx/oxml/comments.py +++ b/src/docx/oxml/comments.py @@ -3,9 +3,10 @@ from __future__ import annotations import datetime as dt +import secrets from typing import TYPE_CHECKING, Callable, cast -from docx.oxml.ns import nsdecls +from docx.oxml.ns import nsdecls, qn from docx.oxml.parser import parse_xml from docx.oxml.simpletypes import ST_DateTime, ST_DecimalNumber, ST_String from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, RequiredAttribute, ZeroOrMore @@ -39,32 +40,67 @@ def add_comment(self) -> CT_Comment: adding additional paragraphs as needed. """ next_id = self._next_available_comment_id() - comment = cast( - CT_Comment, - parse_xml( - f'' - f" " - f" " - f' ' - f" " - f" " - f" " - f' ' - f" " - f" " - f" " - f" " - f"" - ), - ) + para_id = self._next_unique_para_id() + comment = cast(CT_Comment, parse_xml(self._new_comment_xml(next_id, para_id))) + self.append(comment) + return comment + + def add_reply(self, parent_paraId: str) -> CT_Comment: + """Return newly added reply `w:comment` child linked to the parent via `paraIdParent`. + + The reply comment has `w16cid:paraIdParent` set to `parent_paraId`, linking it to the + parent comment. + """ + next_id = self._next_available_comment_id() + para_id = self._next_unique_para_id() + comment = cast(CT_Comment, parse_xml(self._new_comment_xml(next_id, para_id))) + comment.set(qn("w16cid:paraIdParent"), parent_paraId) self.append(comment) return comment + def _new_comment_xml(self, comment_id: int, para_id: str) -> str: + """Return XML string for a new `w:comment` element.""" + return ( + f'' + f" " + f" " + f' ' + f" " + f" " + f" " + f' ' + f" " + f" " + f" " + f" " + f"" + ) + + def get_replies_for(self, para_id: str) -> list[CT_Comment]: + """Return list of `w:comment` elements whose `w16cid:paraIdParent` matches `para_id`.""" + return self.xpath( + "./w:comment[@w16cid:paraIdParent=$paraId]", + paraId=para_id, + ) + def get_comment_by_id(self, comment_id: int) -> CT_Comment | None: """Return the `w:comment` element identified by `comment_id`, or |None| if not found.""" - comment_elms = self.xpath(f"(./w:comment[@w:id='{comment_id}'])[1]") + comment_elms = self.xpath("(./w:comment[@w:id=$commentId])[1]", commentId=comment_id) return comment_elms[0] if comment_elms else None + def _next_unique_para_id(self) -> str: + """Generate a unique 8-character uppercase hex `paraId` value. + + The value is unique among all `w16cid:paraId` attributes in this `w:comments` element. + """ + used_ids = set(self.xpath("./w:comment/@w16cid:paraId")) + while True: + para_id = secrets.token_hex(4).upper() + if para_id not in used_ids: + return para_id + def _next_available_comment_id(self) -> int: """The next available comment id. @@ -106,6 +142,30 @@ class CT_Comment(BaseOxmlElement): "w:date", ST_DateTime ) + @property + def paraId(self) -> str | None: + """The `w16cid:paraId` attribute value, or |None| if not present.""" + return self.get(qn("w16cid:paraId")) + + @paraId.setter + def paraId(self, value: str | None) -> None: + if value is None: + self.attrib.pop(qn("w16cid:paraId"), None) # pyright: ignore[reportArgumentType] + else: + self.set(qn("w16cid:paraId"), value) + + @property + def paraIdParent(self) -> str | None: + """The `w16cid:paraIdParent` attribute value, or |None| if not present.""" + return self.get(qn("w16cid:paraIdParent")) + + @paraIdParent.setter + def paraIdParent(self, value: str | None) -> None: + if value is None: + self.attrib.pop(qn("w16cid:paraIdParent"), None) # pyright: ignore[reportArgumentType] + else: + self.set(qn("w16cid:paraIdParent"), value) + # -- children -- p = ZeroOrMore("w:p", successors=()) diff --git a/src/docx/oxml/coreprops.py b/src/docx/oxml/coreprops.py index fcff0c7ba..31579821d 100644 --- a/src/docx/oxml/coreprops.py +++ b/src/docx/oxml/coreprops.py @@ -24,25 +24,186 @@ class CT_CoreProperties(BaseOxmlElement): get_or_add_revision: Callable[[], etree_Element] - category = ZeroOrOne("cp:category", successors=()) - contentStatus = ZeroOrOne("cp:contentStatus", successors=()) - created = ZeroOrOne("dcterms:created", successors=()) - creator = ZeroOrOne("dc:creator", successors=()) - description = ZeroOrOne("dc:description", successors=()) - identifier = ZeroOrOne("dc:identifier", successors=()) - keywords = ZeroOrOne("cp:keywords", successors=()) - language = ZeroOrOne("dc:language", successors=()) - lastModifiedBy = ZeroOrOne("cp:lastModifiedBy", successors=()) - lastPrinted = ZeroOrOne("cp:lastPrinted", successors=()) - modified = ZeroOrOne("dcterms:modified", successors=()) + category = ZeroOrOne( + "cp:category", + successors=( + "cp:contentStatus", + "dcterms:created", + "dc:creator", + "dc:description", + "dc:identifier", + "cp:keywords", + "dc:language", + "cp:lastModifiedBy", + "cp:lastPrinted", + "dcterms:modified", + "cp:revision", + "dc:subject", + "dc:title", + "cp:version", + ), + ) + contentStatus = ZeroOrOne( + "cp:contentStatus", + successors=( + "dcterms:created", + "dc:creator", + "dc:description", + "dc:identifier", + "cp:keywords", + "dc:language", + "cp:lastModifiedBy", + "cp:lastPrinted", + "dcterms:modified", + "cp:revision", + "dc:subject", + "dc:title", + "cp:version", + ), + ) + created = ZeroOrOne( + "dcterms:created", + successors=( + "dc:creator", + "dc:description", + "dc:identifier", + "cp:keywords", + "dc:language", + "cp:lastModifiedBy", + "cp:lastPrinted", + "dcterms:modified", + "cp:revision", + "dc:subject", + "dc:title", + "cp:version", + ), + ) + creator = ZeroOrOne( + "dc:creator", + successors=( + "dc:description", + "dc:identifier", + "cp:keywords", + "dc:language", + "cp:lastModifiedBy", + "cp:lastPrinted", + "dcterms:modified", + "cp:revision", + "dc:subject", + "dc:title", + "cp:version", + ), + ) + description = ZeroOrOne( + "dc:description", + successors=( + "dc:identifier", + "cp:keywords", + "dc:language", + "cp:lastModifiedBy", + "cp:lastPrinted", + "dcterms:modified", + "cp:revision", + "dc:subject", + "dc:title", + "cp:version", + ), + ) + identifier = ZeroOrOne( + "dc:identifier", + successors=( + "cp:keywords", + "dc:language", + "cp:lastModifiedBy", + "cp:lastPrinted", + "dcterms:modified", + "cp:revision", + "dc:subject", + "dc:title", + "cp:version", + ), + ) + keywords = ZeroOrOne( + "cp:keywords", + successors=( + "dc:language", + "cp:lastModifiedBy", + "cp:lastPrinted", + "dcterms:modified", + "cp:revision", + "dc:subject", + "dc:title", + "cp:version", + ), + ) + language = ZeroOrOne( + "dc:language", + successors=( + "cp:lastModifiedBy", + "cp:lastPrinted", + "dcterms:modified", + "cp:revision", + "dc:subject", + "dc:title", + "cp:version", + ), + ) + lastModifiedBy = ZeroOrOne( + "cp:lastModifiedBy", + successors=( + "cp:lastPrinted", + "dcterms:modified", + "cp:revision", + "dc:subject", + "dc:title", + "cp:version", + ), + ) + lastPrinted = ZeroOrOne( + "cp:lastPrinted", + successors=( + "dcterms:modified", + "cp:revision", + "dc:subject", + "dc:title", + "cp:version", + ), + ) + modified = ZeroOrOne( + "dcterms:modified", + successors=( + "cp:revision", + "dc:subject", + "dc:title", + "cp:version", + ), + ) revision: etree_Element | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] - "cp:revision", successors=() + "cp:revision", + successors=( + "dc:subject", + "dc:title", + "cp:version", + ), + ) + subject = ZeroOrOne( + "dc:subject", + successors=( + "dc:title", + "cp:version", + ), + ) + title = ZeroOrOne( + "dc:title", + successors=( + "cp:version", + ), ) - subject = ZeroOrOne("dc:subject", successors=()) - title = ZeroOrOne("dc:title", successors=()) version = ZeroOrOne("cp:version", successors=()) - _coreProperties_tmpl = "\n" % nsdecls("cp", "dc", "dcterms") + _coreProperties_tmpl = "\n" % nsdecls( + "cp", "dc", "dcmitype", "dcterms", "xsi" + ) @classmethod def new(cls) -> CT_CoreProperties: diff --git a/src/docx/oxml/drawing.py b/src/docx/oxml/drawing.py index 5b627f973..c3cc927b4 100644 --- a/src/docx/oxml/drawing.py +++ b/src/docx/oxml/drawing.py @@ -4,8 +4,47 @@ those to move over here as we have reason to touch them. """ -from docx.oxml.xmlchemy import BaseOxmlElement +from __future__ import annotations + +from typing import TYPE_CHECKING, List + +from docx.oxml.xmlchemy import BaseOxmlElement, ZeroOrMore, ZeroOrOne + +if TYPE_CHECKING: + from docx.oxml.text.paragraph import CT_P class CT_Drawing(BaseOxmlElement): """`` element, containing a DrawingML object like a picture or chart.""" + + @property + def txbxContent_lst(self) -> List[CT_TxbxContent]: + """All `` descendants (text frames in shapes).""" + return self.xpath(".//wps:txbx/w:txbxContent") + + +class CT_WordprocessingShape(BaseOxmlElement): + """`` element, a WordprocessingML shape.""" + + txbx: CT_TextBox | None = ZeroOrOne("wps:txbx") # pyright: ignore[reportAssignmentType] + + +class CT_TextBox(BaseOxmlElement): + """`` element, containing a text box with ``.""" + + txbxContent: CT_TxbxContent | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:txbxContent" + ) + + +class CT_TxbxContent(BaseOxmlElement): + """`` element, containing paragraphs inside a text box.""" + + p_lst: List[CT_P] + + p = ZeroOrMore("w:p") + + @property + def text(self) -> str: + """Concatenated text of all paragraphs, separated by newlines.""" + return "\n".join(p.text for p in self.p_lst) diff --git a/src/docx/oxml/endnotes.py b/src/docx/oxml/endnotes.py new file mode 100644 index 000000000..f2b7f38c7 --- /dev/null +++ b/src/docx/oxml/endnotes.py @@ -0,0 +1,123 @@ +"""Custom element classes related to the endnotes part.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable, cast + +from docx.oxml.ns import nsdecls +from docx.oxml.parser import parse_xml +from docx.oxml.simpletypes import ST_DecimalNumber, ST_String +from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, RequiredAttribute, ZeroOrMore + +if TYPE_CHECKING: + from docx.oxml.table import CT_Tbl + from docx.oxml.text.paragraph import CT_P + + +class CT_Endnotes(BaseOxmlElement): + """`w:endnotes` element, the root element for the endnotes part.""" + + endnote_lst: list[CT_Endnote] + + endnote = ZeroOrMore("w:endnote") + + def add_endnote(self) -> CT_Endnote: + """Return newly added `w:endnote` child element. + + The returned `w:endnote` element has a unique `w:id` value and contains a single + paragraph with an endnote reference run. Content is added by adding runs to this first + paragraph and by adding additional paragraphs as needed. + """ + next_id = self._next_available_endnote_id() + endnote = cast( + CT_Endnote, + parse_xml( + f'' + f" " + f" " + f' ' + f" " + f" " + f" " + f' ' + f" " + f" " + f" " + f" " + f"" + ), + ) + self.append(endnote) + return endnote + + def _next_available_endnote_id(self) -> int: + """The next available endnote id. + + IDs 0 and 1 are reserved for the separator and continuation separator. User endnotes + start at 2. + """ + used_ids = [int(x) for x in self.xpath("./w:endnote/@w:id")] + + next_id = max(used_ids, default=1) + 1 + + if next_id < 2: + return 2 + + if next_id <= 2**31 - 1: + return next_id + + # -- fall-back to enumerating all used ids to find the first unused one -- + used_id_set = set(used_ids) + for expected_id in range(2, 2**31): + if expected_id not in used_id_set: + return expected_id + + raise ValueError("No available endnote ID: document has reached the maximum endnote count.") + + +class CT_Endnote(BaseOxmlElement): + """`w:endnote` element, representing a single endnote. + + An endnote can contain paragraphs and tables, much like a comment or table-cell. + """ + + id: int = RequiredAttribute("w:id", ST_DecimalNumber) # pyright: ignore[reportAssignmentType] + type: str | None = OptionalAttribute("w:type", ST_String) # pyright: ignore[reportAssignmentType] + + p = ZeroOrMore("w:p", successors=()) + tbl = ZeroOrMore("w:tbl", successors=()) + + # -- type-declarations for methods added by metaclass -- + add_p: Callable[[], CT_P] + p_lst: list[CT_P] + tbl_lst: list[CT_Tbl] + _insert_tbl: Callable[[CT_Tbl], CT_Tbl] + + def clear_content(self) -> None: + """Remove all child elements and add a single empty paragraph. + + The empty paragraph has the "EndnoteText" style applied and contains a + `w:endnoteRef` run so the auto-numbered reference mark is preserved. + """ + for child in list(self): + self.remove(child) + self.append( + parse_xml( + f'' + f" " + f' ' + f" " + f" " + f" " + f' ' + f" " + f" " + f" " + f"" + ) + ) + + @property + def inner_content_elements(self) -> list[CT_P | CT_Tbl]: + """Return all `w:p` and `w:tbl` elements in this endnote.""" + return self.xpath("./w:p | ./w:tbl") diff --git a/src/docx/oxml/footnotes.py b/src/docx/oxml/footnotes.py new file mode 100644 index 000000000..d6befa29d --- /dev/null +++ b/src/docx/oxml/footnotes.py @@ -0,0 +1,123 @@ +"""Custom element classes related to the footnotes part.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable, cast + +from docx.oxml.ns import nsdecls +from docx.oxml.parser import parse_xml +from docx.oxml.simpletypes import ST_DecimalNumber, ST_String +from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, RequiredAttribute, ZeroOrMore + +if TYPE_CHECKING: + from docx.oxml.table import CT_Tbl + from docx.oxml.text.paragraph import CT_P + + +class CT_Footnotes(BaseOxmlElement): + """`w:footnotes` element, the root element for the footnotes part.""" + + footnote_lst: list[CT_Footnote] + + footnote = ZeroOrMore("w:footnote") + + def add_footnote(self) -> CT_Footnote: + """Return newly added `w:footnote` child element. + + The returned `w:footnote` element has a unique `w:id` value and contains a single + paragraph with a footnote reference run. Content is added by adding runs to this first + paragraph and by adding additional paragraphs as needed. + """ + next_id = self._next_available_footnote_id() + footnote = cast( + CT_Footnote, + parse_xml( + f'' + f" " + f" " + f' ' + f" " + f" " + f" " + f' ' + f" " + f" " + f" " + f" " + f"" + ), + ) + self.append(footnote) + return footnote + + def _next_available_footnote_id(self) -> int: + """The next available footnote id. + + IDs 0 and 1 are reserved for the separator and continuation separator. User footnotes + start at 2. + """ + used_ids = [int(x) for x in self.xpath("./w:footnote/@w:id")] + + next_id = max(used_ids, default=1) + 1 + + if next_id < 2: + return 2 + + if next_id <= 2**31 - 1: + return next_id + + # -- fall-back to enumerating all used ids to find the first unused one -- + used_id_set = set(used_ids) + for expected_id in range(2, 2**31): + if expected_id not in used_id_set: + return expected_id + + raise ValueError("No available footnote ID: document has reached the maximum footnote count.") + + +class CT_Footnote(BaseOxmlElement): + """`w:footnote` element, representing a single footnote. + + A footnote can contain paragraphs and tables, much like a comment or table-cell. + """ + + id: int = RequiredAttribute("w:id", ST_DecimalNumber) # pyright: ignore[reportAssignmentType] + type: str | None = OptionalAttribute("w:type", ST_String) # pyright: ignore[reportAssignmentType] + + p = ZeroOrMore("w:p", successors=()) + tbl = ZeroOrMore("w:tbl", successors=()) + + # -- type-declarations for methods added by metaclass -- + add_p: Callable[[], CT_P] + p_lst: list[CT_P] + tbl_lst: list[CT_Tbl] + _insert_tbl: Callable[[CT_Tbl], CT_Tbl] + + def clear_content(self) -> None: + """Remove all child elements and add a single empty paragraph. + + The empty paragraph has the "FootnoteText" style applied and contains a + `w:footnoteRef` run so the auto-numbered reference mark is preserved. + """ + for child in list(self): + self.remove(child) + self.append( + parse_xml( + f'' + f" " + f' ' + f" " + f" " + f" " + f' ' + f" " + f" " + f" " + f"" + ) + ) + + @property + def inner_content_elements(self) -> list[CT_P | CT_Tbl]: + """Return all `w:p` and `w:tbl` elements in this footnote.""" + return self.xpath("./w:p | ./w:tbl") diff --git a/src/docx/oxml/ns.py b/src/docx/oxml/ns.py index ce03940f7..7f85a0397 100644 --- a/src/docx/oxml/ns.py +++ b/src/docx/oxml/ns.py @@ -6,6 +6,7 @@ nsmap = { "a": "http://schemas.openxmlformats.org/drawingml/2006/main", + "asvg": "http://schemas.microsoft.com/office/drawing/2016/SVG/main", "c": "http://schemas.openxmlformats.org/drawingml/2006/chart", "cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties", "dc": "http://purl.org/dc/elements/1.1/", @@ -18,7 +19,10 @@ "sl": "http://schemas.openxmlformats.org/schemaLibrary/2006/main", "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", "w14": "http://schemas.microsoft.com/office/word/2010/wordml", + "w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid", "wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", + "wpg": "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup", + "wps": "http://schemas.microsoft.com/office/word/2010/wordprocessingShape", "xml": "http://www.w3.org/XML/1998/namespace", "xsi": "http://www.w3.org/2001/XMLSchema-instance", } diff --git a/src/docx/oxml/numbering.py b/src/docx/oxml/numbering.py index 3512de655..ea044f334 100644 --- a/src/docx/oxml/numbering.py +++ b/src/docx/oxml/numbering.py @@ -57,23 +57,6 @@ class CT_NumPr(BaseOxmlElement): ilvl = ZeroOrOne("w:ilvl", successors=("w:numId", "w:numberingChange", "w:ins")) numId = ZeroOrOne("w:numId", successors=("w:numberingChange", "w:ins")) - # @ilvl.setter - # def _set_ilvl(self, val): - # """ - # Get or add a child and set its ``w:val`` attribute to `val`. - # """ - # ilvl = self.get_or_add_ilvl() - # ilvl.val = val - - # @numId.setter - # def numId(self, val): - # """ - # Get or add a child and set its ``w:val`` attribute to - # `val`. - # """ - # numId = self.get_or_add_numId() - # numId.val = val - class CT_Numbering(BaseOxmlElement): """```` element, the root element of a numbering part, i.e. @@ -91,9 +74,8 @@ def add_num(self, abstractNum_id): def num_having_numId(self, numId): """Return the ```` child element having ``numId`` attribute matching `numId`.""" - xpath = './w:num[@w:numId="%d"]' % numId try: - return self.xpath(xpath)[0] + return self.xpath("./w:num[@w:numId=$numId]", numId=str(numId))[0] except IndexError: raise KeyError("no element with numId %d" % numId) diff --git a/src/docx/oxml/parser.py b/src/docx/oxml/parser.py index e16ba30ba..58a64d603 100644 --- a/src/docx/oxml/parser.py +++ b/src/docx/oxml/parser.py @@ -15,8 +15,15 @@ # -- configure XML parser -- +# Security: resolve_entities=False prevents XXE attacks, no_network=True prevents +# network access during parsing, huge_tree=False prevents XML bombs (billion laughs). element_class_lookup = etree.ElementNamespaceClassLookup() -oxml_parser = etree.XMLParser(remove_blank_text=True, resolve_entities=False) +oxml_parser = etree.XMLParser( + remove_blank_text=True, + resolve_entities=False, + no_network=True, + huge_tree=False, +) oxml_parser.set_element_class_lookup(element_class_lookup) diff --git a/src/docx/oxml/section.py b/src/docx/oxml/section.py index 71072e2df..00942f8b8 100644 --- a/src/docx/oxml/section.py +++ b/src/docx/oxml/section.py @@ -11,7 +11,13 @@ from docx.enum.section import WD_HEADER_FOOTER, WD_ORIENTATION, WD_SECTION_START from docx.oxml.ns import nsmap from docx.oxml.shared import CT_OnOff -from docx.oxml.simpletypes import ST_SignedTwipsMeasure, ST_TwipsMeasure, XsdString +from docx.oxml.simpletypes import ( + ST_DecimalNumber, + ST_OnOff, + ST_SignedTwipsMeasure, + ST_TwipsMeasure, + XsdString, +) from docx.oxml.table import CT_Tbl from docx.oxml.text.paragraph import CT_P from docx.oxml.xmlchemy import ( @@ -26,6 +32,35 @@ BlockElement: TypeAlias = "CT_P | CT_Tbl" +class CT_Col(BaseOxmlElement): + """```` element, defining width and spacing for an individual column.""" + + w: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:w", ST_TwipsMeasure + ) + space: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:space", ST_TwipsMeasure + ) + + +class CT_Cols(BaseOxmlElement): + """```` element, defining column layout for a section.""" + + col_lst: List[CT_Col] + + col = ZeroOrMore("w:col", successors=()) + + num: int | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:num", ST_DecimalNumber + ) + space: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:space", ST_TwipsMeasure + ) + equalWidth: bool | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:equalWidth", ST_OnOff + ) + + class CT_HdrFtr(BaseOxmlElement): """`w:hdr` and `w:ftr`, the root element for header and footer part respectively.""" @@ -100,6 +135,7 @@ class CT_PageSz(BaseOxmlElement): class CT_SectPr(BaseOxmlElement): """`w:sectPr` element, the container element for section properties.""" + get_or_add_cols: Callable[[], CT_Cols] get_or_add_pgMar: Callable[[], CT_PageMar] get_or_add_pgSz: Callable[[], CT_PageSz] get_or_add_titlePg: Callable[[], CT_OnOff] @@ -142,6 +178,9 @@ class CT_SectPr(BaseOxmlElement): pgMar: CT_PageMar | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:pgMar", successors=_tag_seq[5:] ) + cols: CT_Cols | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:cols", successors=_tag_seq[10:] + ) titlePg: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:titlePg", successors=_tag_seq[14:] ) diff --git a/src/docx/oxml/settings.py b/src/docx/oxml/settings.py index d5bb41a6d..36f2def29 100644 --- a/src/docx/oxml/settings.py +++ b/src/docx/oxml/settings.py @@ -4,17 +4,79 @@ from typing import TYPE_CHECKING, Callable -from docx.oxml.xmlchemy import BaseOxmlElement, ZeroOrOne +from docx.oxml.simpletypes import ST_DecimalNumber, ST_OnOff, ST_String, ST_TwipsMeasure +from docx.oxml.xmlchemy import ( + BaseOxmlElement, + OptionalAttribute, + RequiredAttribute, + ZeroOrMore, + ZeroOrOne, +) if TYPE_CHECKING: from docx.oxml.shared import CT_OnOff + from docx.shared import Length + + +class CT_Zoom(BaseOxmlElement): + """`w:zoom` element, specifying the magnification level for the document.""" + + percent: int | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:percent", ST_DecimalNumber + ) + + +class CT_DocProtect(BaseOxmlElement): + """`w:documentProtection` element, specifying document editing restrictions.""" + + edit: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:edit", ST_String + ) + enforcement: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:enforcement", ST_OnOff, default=False + ) + + +class CT_CompatSetting(BaseOxmlElement): + """`w:compatSetting` element, a single compatibility setting name/value pair.""" + + name: str = RequiredAttribute("w:name", ST_String) # pyright: ignore[reportAssignmentType] + uri: str = RequiredAttribute("w:uri", ST_String) # pyright: ignore[reportAssignmentType] + val: str = RequiredAttribute("w:val", ST_String) # pyright: ignore[reportAssignmentType] + + +class CT_Compat(BaseOxmlElement): + """`w:compat` element, containing document compatibility settings.""" + + _add_compatSetting: Callable[..., CT_CompatSetting] + compatSetting_lst: list[CT_CompatSetting] + + compatSetting = ZeroOrMore("w:compatSetting", successors=()) + + +class CT_DefaultTabStop(BaseOxmlElement): + """`w:defaultTabStop` element, specifying default tab-stop interval.""" + + val: Length = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "w:val", ST_TwipsMeasure + ) class CT_Settings(BaseOxmlElement): """`w:settings` element, root element for the settings part.""" + get_or_add_zoom: Callable[[], CT_Zoom] + _remove_zoom: Callable[[], None] + get_or_add_trackRevisions: Callable[[], CT_OnOff] + _remove_trackRevisions: Callable[[], None] + get_or_add_documentProtection: Callable[[], CT_DocProtect] + _remove_documentProtection: Callable[[], None] + get_or_add_defaultTabStop: Callable[[], CT_DefaultTabStop] + _remove_defaultTabStop: Callable[[], None] get_or_add_evenAndOddHeaders: Callable[[], CT_OnOff] _remove_evenAndOddHeaders: Callable[[], None] + get_or_add_compat: Callable[[], CT_Compat] + _remove_compat: Callable[[], None] _tag_seq = ( "w:writeProtection", @@ -116,14 +178,29 @@ class CT_Settings(BaseOxmlElement): "w:decimalSymbol", "w:listSeparator", ) + zoom: CT_Zoom | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:zoom", successors=_tag_seq[3:] + ) + trackRevisions: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:trackRevisions", successors=_tag_seq[32:] + ) + documentProtection: CT_DocProtect | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:documentProtection", successors=_tag_seq[35:] + ) + defaultTabStop: CT_DefaultTabStop | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:defaultTabStop", successors=_tag_seq[39:] + ) evenAndOddHeaders: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:evenAndOddHeaders", successors=_tag_seq[48:] ) + compat: CT_Compat | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:compat", successors=_tag_seq[81:] + ) del _tag_seq @property def evenAndOddHeaders_val(self) -> bool: - """Value of `w:evenAndOddHeaders/@w:val` or |None| if not present.""" + """Value of `w:evenAndOddHeaders/@w:val` or False if not present.""" evenAndOddHeaders = self.evenAndOddHeaders if evenAndOddHeaders is None: return False @@ -134,5 +211,98 @@ def evenAndOddHeaders_val(self, value: bool | None): if value is None or value is False: self._remove_evenAndOddHeaders() return - self.get_or_add_evenAndOddHeaders().val = value + + @property + def zoom_percent(self) -> int | None: + """Value of `w:zoom/@w:percent` or None if not present.""" + zoom = self.zoom + if zoom is None: + return None + return zoom.percent + + @zoom_percent.setter + def zoom_percent(self, value: int | None): + if value is None: + self._remove_zoom() + return + self.get_or_add_zoom().percent = value + + @property + def trackRevisions_val(self) -> bool: + """True if `w:trackRevisions` is present and enabled.""" + trackRevisions = self.trackRevisions + if trackRevisions is None: + return False + return trackRevisions.val + + @trackRevisions_val.setter + def trackRevisions_val(self, value: bool | None): + if value is None or value is False: + self._remove_trackRevisions() + return + self.get_or_add_trackRevisions().val = value + + @property + def defaultTabStop_val(self) -> Length | None: + """Value of `w:defaultTabStop/@w:val` as a Length or None if not present.""" + defaultTabStop = self.defaultTabStop + if defaultTabStop is None: + return None + return defaultTabStop.val + + @defaultTabStop_val.setter + def defaultTabStop_val(self, value: int | Length | None): + if value is None: + self._remove_defaultTabStop() + return + self.get_or_add_defaultTabStop().val = value + + @property + def documentProtection_edit(self) -> str | None: + """Value of `w:documentProtection/@w:edit` or None if not present.""" + dp = self.documentProtection + if dp is None: + return None + return dp.edit + + @property + def documentProtection_enforcement(self) -> bool: + """True if `w:documentProtection/@w:enforcement` is enabled.""" + dp = self.documentProtection + if dp is None: + return False + return dp.enforcement + + @property + def compatibilityMode(self) -> int | None: + """The compatibility-mode value from `w:compat/w:compatSetting` or None.""" + compat = self.compat + if compat is None: + return None + for setting in compat.compatSetting_lst: + if setting.name == "compatibilityMode": + return int(setting.val) + return None + + @compatibilityMode.setter + def compatibilityMode(self, value: int | None): + if value is None: + compat = self.compat + if compat is not None: + for setting in list(compat.compatSetting_lst): + if setting.name == "compatibilityMode": + compat.remove(setting) + if len(compat.compatSetting_lst) == 0: + self._remove_compat() + return + compat = self.get_or_add_compat() + for setting in compat.compatSetting_lst: + if setting.name == "compatibilityMode": + setting.val = str(value) + return + compat._add_compatSetting( + name="compatibilityMode", + uri="http://schemas.microsoft.com/office/word", + val=str(value), + ) diff --git a/src/docx/oxml/shape.py b/src/docx/oxml/shape.py index c6df8e7b8..218535ad7 100644 --- a/src/docx/oxml/shape.py +++ b/src/docx/oxml/shape.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, cast -from docx.oxml.ns import nsdecls +from docx.oxml.ns import nsdecls, qn from docx.oxml.parser import parse_xml from docx.oxml.simpletypes import ( ST_Coordinate, @@ -102,11 +102,32 @@ def new_pic_inline( inline = cls.new(cx, cy, shape_id, pic) return inline + @classmethod + def new_svg_pic_inline( + cls, + shape_id: int, + fallback_rId: str, + svg_rId: str, + filename: str, + cx: Length, + cy: Length, + ) -> CT_Inline: + """Create `wp:inline` element for an SVG image with PNG fallback. + + The `a:blip` references the fallback PNG via `r:embed`, and the SVG is + referenced via an `asvg:svgBlip` extension element. + """ + pic_id = 0 + pic = CT_Picture.new_svg(pic_id, filename, fallback_rId, svg_rId, cx, cy) + inline = cls.new(cx, cy, shape_id, pic) + return inline + @classmethod def _inline_xml(cls): return ( - "\n" + '\n' ' \n' + ' \n' ' \n' " \n" ' \n' @@ -154,6 +175,61 @@ def new(cls, pic_id: int, filename: str, rId: str, cx: Length, cy: Length) -> CT pic.spPr.cy = cy return pic + @classmethod + def new_svg( + cls, + pic_id: int, + filename: str, + fallback_rId: str, + svg_rId: str, + cx: Length, + cy: Length, + ) -> CT_Picture: + """A new `` element for an SVG image with PNG fallback.""" + pic = parse_xml(cls._svg_pic_xml()) + pic.nvPicPr.cNvPr.id = pic_id + pic.nvPicPr.cNvPr.name = filename + pic.blipFill.blip.embed = fallback_rId + # Set SVG blip rId on the asvg:svgBlip element + svg_blip = pic.find( + ".//" + qn("asvg:svgBlip"), + ) + assert svg_blip is not None, "asvg:svgBlip not found in SVG pic XML" + svg_blip.set(qn("r:embed"), svg_rId) + pic.spPr.cx = cx + pic.spPr.cy = cy + return pic + + @classmethod + def _svg_pic_xml(cls) -> str: + return ( + "\n" + " \n" + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + ' \n' + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + ' \n' + ' \n' + " \n" + ' \n' + " \n" + "" % nsdecls("pic", "a", "r", "asvg") + ) + @classmethod def _pic_xml(cls): return ( diff --git a/src/docx/oxml/simpletypes.py b/src/docx/oxml/simpletypes.py index a0fc87d3f..157a949d0 100644 --- a/src/docx/oxml/simpletypes.py +++ b/src/docx/oxml/simpletypes.py @@ -178,6 +178,24 @@ def validate(cls, value: Any) -> None: cls.validate_int_in_range(value, 0, 18446744073709551615) +class ST_EighthPointMeasure(BaseIntType): + """Measurement in eighths of a point, used for border widths (w:sz attribute).""" + + @classmethod + def convert_from_xml(cls, str_value: str) -> int: + return int(str_value) + + @classmethod + def convert_to_xml(cls, value: int) -> str: + return str(value) + + @classmethod + def validate(cls, value: Any) -> None: + cls.validate_int(value) + if value < 0 or value > 255: + raise ValueError("value must be in range 0 to 255 inclusive, got %d" % value) + + class ST_BrClear(XsdString): @classmethod def validate(cls, value: str) -> None: @@ -214,6 +232,34 @@ def validate(cls, value: Any) -> None: cls.validate_int_in_range(value, -27273042329600, 27273042316900) +class ST_EighthPointMeasure(BaseIntType): + """Measurement in eighths of a point, e.g. sz="8" represents 1 point.""" + + @classmethod + def convert_from_xml(cls, str_value: str) -> Length: + return Pt(int(str_value) / 8.0) + + @classmethod + def convert_to_xml(cls, value: int | Length) -> str: + emu = Emu(value) + eighth_points = int(round(emu.pt * 8)) + return str(eighth_points) + + +class ST_PointMeasure(BaseIntType): + """Measurement in whole points, e.g. space="4" represents 4 points.""" + + @classmethod + def convert_from_xml(cls, str_value: str) -> Length: + return Pt(int(str_value)) + + @classmethod + def convert_to_xml(cls, value: int | Length) -> str: + emu = Emu(value) + points = int(round(emu.pt)) + return str(points) + + class ST_DateTime(BaseSimpleType): @classmethod def convert_from_xml(cls, str_value: str) -> dt.datetime: diff --git a/src/docx/oxml/styles.py b/src/docx/oxml/styles.py index fb0e5d0dd..2dff61fa7 100644 --- a/src/docx/oxml/styles.py +++ b/src/docx/oxml/styles.py @@ -54,7 +54,7 @@ def bool_prop(self, attr_name): def get_by_name(self, name): """Return the `w:lsdException` child having `name`, or |None| if not found.""" - found = self.xpath('w:lsdException[@w:name="%s"]' % name) + found = self.xpath("w:lsdException[@w:name=$name]", name=name) if not found: return None return found[0] @@ -304,16 +304,14 @@ def get_by_id(self, styleId: str) -> CT_Style | None: |None| if not found. """ - xpath = f'w:style[@w:styleId="{styleId}"]' - return next(iter(self.xpath(xpath)), None) + return next(iter(self.xpath("w:style[@w:styleId=$styleId]", styleId=styleId)), None) def get_by_name(self, name: str) -> CT_Style | None: """`w:style` child with `w:name` grandchild having value `name`. |None| if not found. """ - xpath = 'w:style[w:name/@w:val="%s"]' % name - return next(iter(self.xpath(xpath)), None) + return next(iter(self.xpath("w:style[w:name/@w:val=$name]", name=name)), None) def _iter_styles(self): """Generate each of the `w:style` child elements in document order.""" diff --git a/src/docx/oxml/table.py b/src/docx/oxml/table.py index 9457da207..cd0614372 100644 --- a/src/docx/oxml/table.py +++ b/src/docx/oxml/table.py @@ -4,17 +4,26 @@ from typing import TYPE_CHECKING, Callable, cast -from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_ROW_HEIGHT_RULE, WD_TABLE_DIRECTION +from docx.enum.table import ( + WD_BORDER_STYLE, + WD_CELL_VERTICAL_ALIGNMENT, + WD_ROW_HEIGHT_RULE, + WD_SHADING_PATTERN, + WD_TABLE_DIRECTION, +) from docx.exceptions import InvalidSpanError from docx.oxml.ns import nsdecls, qn from docx.oxml.parser import parse_xml from docx.oxml.shared import CT_DecimalNumber from docx.oxml.simpletypes import ( + ST_EighthPointMeasure, + ST_HexColor, ST_Merge, ST_TblLayoutType, ST_TblWidth, ST_TwipsMeasure, XsdInt, + XsdUnsignedInt, ) from docx.oxml.text.paragraph import CT_P from docx.oxml.xmlchemy import ( @@ -35,6 +44,128 @@ from docx.oxml.text.parfmt import CT_Jc +class CT_Shd(BaseOxmlElement): + """`w:shd` element, specifying shading (background color and pattern) for a table cell.""" + + val: WD_SHADING_PATTERN | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:val", WD_SHADING_PATTERN + ) + color: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:color", ST_HexColor + ) + fill: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:fill", ST_HexColor + ) + + +class CT_Border(BaseOxmlElement): + """`w:top`, `w:bottom`, `w:left`, `w:right`, `w:insideH`, `w:insideV` border element. + + Defines a single border edge with style, width, color, and spacing attributes. + """ + + val: WD_BORDER_STYLE | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:val", WD_BORDER_STYLE + ) + sz: int | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:sz", ST_EighthPointMeasure + ) + color: str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:color", ST_HexColor + ) + space: int | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:space", XsdUnsignedInt + ) + + +class CT_TblBorders(BaseOxmlElement): + """`w:tblBorders` element, child of `w:tblPr`. + + Contains border definitions for the table: top, left, bottom, right, insideH, insideV. + """ + + get_or_add_top: Callable[[], CT_Border] + get_or_add_left: Callable[[], CT_Border] + get_or_add_bottom: Callable[[], CT_Border] + get_or_add_right: Callable[[], CT_Border] + get_or_add_insideH: Callable[[], CT_Border] + get_or_add_insideV: Callable[[], CT_Border] + _remove_top: Callable[[], None] + _remove_left: Callable[[], None] + _remove_bottom: Callable[[], None] + _remove_right: Callable[[], None] + _remove_insideH: Callable[[], None] + _remove_insideV: Callable[[], None] + + _tag_seq = ( + "w:top", + "w:left", + "w:bottom", + "w:right", + "w:insideH", + "w:insideV", + ) + top: CT_Border | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:top", successors=_tag_seq[1:] + ) + left: CT_Border | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:left", successors=_tag_seq[2:] + ) + bottom: CT_Border | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:bottom", successors=_tag_seq[3:] + ) + right: CT_Border | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:right", successors=_tag_seq[4:] + ) + insideH: CT_Border | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:insideH", successors=_tag_seq[5:] + ) + insideV: CT_Border | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:insideV", successors=() + ) + del _tag_seq + + +class CT_TcBorders(BaseOxmlElement): + """`w:tcBorders` element, child of `w:tcPr`. + + Contains border definitions for a table cell: top, left, bottom, right. + """ + + get_or_add_top: Callable[[], CT_Border] + get_or_add_left: Callable[[], CT_Border] + get_or_add_bottom: Callable[[], CT_Border] + get_or_add_right: Callable[[], CT_Border] + _remove_top: Callable[[], None] + _remove_left: Callable[[], None] + _remove_bottom: Callable[[], None] + _remove_right: Callable[[], None] + + _tag_seq = ( + "w:top", + "w:left", + "w:bottom", + "w:right", + "w:insideH", + "w:insideV", + "w:tl2br", + "w:tr2bl", + ) + top: CT_Border | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:top", successors=_tag_seq[1:] + ) + left: CT_Border | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:left", successors=_tag_seq[2:] + ) + bottom: CT_Border | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:bottom", successors=_tag_seq[3:] + ) + right: CT_Border | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:right", successors=_tag_seq[4:] + ) + del _tag_seq + + class CT_Height(BaseOxmlElement): """Used for `w:trHeight` to specify a row height and row height rule.""" @@ -60,6 +191,23 @@ class CT_Row(BaseOxmlElement): trPr: CT_TrPr | None = ZeroOrOne("w:trPr") # pyright: ignore[reportAssignmentType] tc = ZeroOrMore("w:tc") + @property + def allow_break_across_pages(self) -> bool: + """Value of `./w:trPr/w:cantSplit`, with inverted logic. + + |True| when row is allowed to break across pages (default), |False| when the + entire row must be kept on a single page. + """ + trPr = self.trPr + if trPr is None: + return True + return trPr.allow_break_across_pages + + @allow_break_across_pages.setter + def allow_break_across_pages(self, value: bool | None): + trPr = self.get_or_add_trPr() + trPr.allow_break_across_pages = value + @property def grid_after(self) -> int: """The number of unpopulated layout-grid cells at the end of this row.""" @@ -76,6 +224,19 @@ def grid_before(self) -> int: return 0 return trPr.grid_before + @property + def is_header(self) -> bool: + """True when this row is a header row that repeats on each page.""" + trPr = self.trPr + if trPr is None: + return False + return trPr.is_header + + @is_header.setter + def is_header(self, value: bool | None) -> None: + trPr = self.get_or_add_trPr() + trPr.is_header = value + def tc_at_grid_offset(self, grid_offset: int) -> CT_Tc: """The `tc` element in this tr at exact `grid offset`. @@ -300,10 +461,12 @@ class CT_TblPr(BaseOxmlElement): get_or_add_bidiVisual: Callable[[], CT_OnOff] get_or_add_jc: Callable[[], CT_Jc] + get_or_add_tblBorders: Callable[[], CT_TblBorders] get_or_add_tblLayout: Callable[[], CT_TblLayoutType] _add_tblStyle: Callable[[], CT_String] _remove_bidiVisual: Callable[[], None] _remove_jc: Callable[[], None] + _remove_tblBorders: Callable[[], None] _remove_tblStyle: Callable[[], None] _tag_seq = ( @@ -335,6 +498,9 @@ class CT_TblPr(BaseOxmlElement): jc: CT_Jc | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:jc", successors=_tag_seq[8:] ) + tblBorders: CT_TblBorders | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:tblBorders", successors=_tag_seq[11:] + ) tblLayout: CT_TblLayoutType | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:tblLayout", successors=_tag_seq[13:] ) @@ -785,10 +951,14 @@ class CT_TcPr(BaseOxmlElement): """```` element, defining table cell properties.""" get_or_add_gridSpan: Callable[[], CT_DecimalNumber] + get_or_add_shd: Callable[[], CT_Shd] + get_or_add_tcBorders: Callable[[], CT_TcBorders] get_or_add_tcW: Callable[[], CT_TblWidth] get_or_add_vAlign: Callable[[], CT_VerticalJc] _add_vMerge: Callable[[], CT_VMerge] _remove_gridSpan: Callable[[], None] + _remove_shd: Callable[[], None] + _remove_tcBorders: Callable[[], None] _remove_vAlign: Callable[[], None] _remove_vMerge: Callable[[], None] @@ -821,6 +991,12 @@ class CT_TcPr(BaseOxmlElement): vMerge: CT_VMerge | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:vMerge", successors=_tag_seq[5:] ) + tcBorders: CT_TcBorders | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:tcBorders", successors=_tag_seq[6:] + ) + shd: CT_Shd | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:shd", successors=_tag_seq[7:] + ) vAlign: CT_VerticalJc | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:vAlign", successors=_tag_seq[12:] ) @@ -892,7 +1068,11 @@ def width(self, value: Length): class CT_TrPr(BaseOxmlElement): """```` element, defining table row properties.""" + get_or_add_cantSplit: Callable[[], CT_OnOff] + get_or_add_tblHeader: Callable[[], CT_OnOff] get_or_add_trHeight: Callable[[], CT_Height] + _remove_cantSplit: Callable[[], None] + _remove_tblHeader: Callable[[], None] _tag_seq = ( "w:cnfStyle", @@ -911,17 +1091,43 @@ class CT_TrPr(BaseOxmlElement): "w:del", "w:trPrChange", ) + cantSplit: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:cantSplit", successors=_tag_seq[7:] + ) gridAfter: CT_DecimalNumber | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:gridAfter", successors=_tag_seq[4:] ) gridBefore: CT_DecimalNumber | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:gridBefore", successors=_tag_seq[3:] ) + tblHeader: CT_OnOff | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:tblHeader", successors=_tag_seq[9:] + ) trHeight: CT_Height | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] "w:trHeight", successors=_tag_seq[8:] ) del _tag_seq + @property + def allow_break_across_pages(self) -> bool: + """Value of `w:cantSplit` element, with inverted logic. + + |True| when `w:cantSplit` is not present or its val is False, |False| when + `w:cantSplit` is present and its val is True, |None| is not used because + presence semantics give us a definitive answer. + """ + cantSplit = self.cantSplit + if cantSplit is None: + return True + return not cantSplit.val + + @allow_break_across_pages.setter + def allow_break_across_pages(self, value: bool | None): + if value is None or value is True: + self._remove_cantSplit() + else: + self.get_or_add_cantSplit().val = True + @property def grid_after(self) -> int: """The number of unpopulated layout-grid cells at the end of this row.""" @@ -934,6 +1140,21 @@ def grid_before(self) -> int: gridBefore = self.gridBefore return 0 if gridBefore is None else gridBefore.val + @property + def is_header(self) -> bool: + """True when `w:tblHeader` child is present, False otherwise.""" + tblHeader = self.tblHeader + if tblHeader is None: + return False + return tblHeader.val + + @is_header.setter + def is_header(self, value: bool | None) -> None: + if value is None or value is False: + self._remove_tblHeader() + else: + self.get_or_add_tblHeader().val = True + @property def trHeight_hRule(self) -> WD_ROW_HEIGHT_RULE | None: """Return the value of `w:trHeight@w:hRule`, or |None| if not present.""" diff --git a/src/docx/oxml/text/font.py b/src/docx/oxml/text/font.py index 32eb567ba..35284bcc5 100644 --- a/src/docx/oxml/text/font.py +++ b/src/docx/oxml/text/font.py @@ -44,6 +44,8 @@ class CT_Fonts(BaseOxmlElement): ascii: str | None = OptionalAttribute("w:ascii", ST_String) hAnsi: str | None = OptionalAttribute("w:hAnsi", ST_String) + eastAsia: str | None = OptionalAttribute("w:eastAsia", ST_String) + cs: str | None = OptionalAttribute("w:cs", ST_String) class CT_Highlight(BaseOxmlElement): @@ -63,15 +65,19 @@ class CT_RPr(BaseOxmlElement): get_or_add_color: Callable[[], CT_Color] get_or_add_highlight: Callable[[], CT_Highlight] + get_or_add_kern: Callable[[], CT_HpsMeasure] get_or_add_rFonts: Callable[[], CT_Fonts] + get_or_add_spacing: Callable[[], BaseOxmlElement] get_or_add_sz: Callable[[], CT_HpsMeasure] get_or_add_vertAlign: Callable[[], CT_VerticalAlignRun] _add_rStyle: Callable[..., CT_String] _add_u: Callable[[], CT_Underline] _remove_color: Callable[[], None] _remove_highlight: Callable[[], None] + _remove_kern: Callable[[], None] _remove_rFonts: Callable[[], None] _remove_rStyle: Callable[[], None] + _remove_spacing: Callable[[], None] _remove_sz: Callable[[], None] _remove_u: Callable[[], None] _remove_vertAlign: Callable[[], None] @@ -136,6 +142,8 @@ class CT_RPr(BaseOxmlElement): vanish = ZeroOrOne("w:vanish", successors=_tag_seq[17:]) webHidden = ZeroOrOne("w:webHidden", successors=_tag_seq[18:]) color: CT_Color | None = ZeroOrOne("w:color", successors=_tag_seq[19:]) + spacing = ZeroOrOne("w:spacing", successors=_tag_seq[20:]) + kern: CT_HpsMeasure | None = ZeroOrOne("w:kern", successors=_tag_seq[22:]) sz: CT_HpsMeasure | None = ZeroOrOne("w:sz", successors=_tag_seq[24:]) highlight: CT_Highlight | None = ZeroOrOne("w:highlight", successors=_tag_seq[26:]) u: CT_Underline | None = ZeroOrOne("w:u", successors=_tag_seq[27:]) @@ -205,6 +213,36 @@ def rFonts_hAnsi(self, value: str | None): rFonts = self.get_or_add_rFonts() rFonts.hAnsi = value + @property + def rFonts_eastAsia(self) -> str | None: + """The value of `w:rFonts/@w:eastAsia` or |None| if not present.""" + rFonts = self.rFonts + if rFonts is None: + return None + return rFonts.eastAsia + + @rFonts_eastAsia.setter + def rFonts_eastAsia(self, value: str | None) -> None: + if value is None and self.rFonts is None: + return + rFonts = self.get_or_add_rFonts() + rFonts.eastAsia = value + + @property + def rFonts_cs(self) -> str | None: + """The value of `w:rFonts/@w:cs` or |None| if not present.""" + rFonts = self.rFonts + if rFonts is None: + return None + return rFonts.cs + + @rFonts_cs.setter + def rFonts_cs(self, value: str | None) -> None: + if value is None and self.rFonts is None: + return + rFonts = self.get_or_add_rFonts() + rFonts.cs = value + @property def style(self) -> str | None: """String in `./w:rStyle/@val`, or None if `w:rStyle` is not present.""" @@ -270,6 +308,38 @@ def superscript(self, value: bool | None): elif self.vertAlign is not None and self.vertAlign.val == ST_VerticalAlignRun.SUPERSCRIPT: self._remove_vertAlign() + @property + def kern_val(self) -> Length | None: + """Value of `w:kern/@w:val` or |None| if not present.""" + kern = self.kern + if kern is None: + return None + return kern.val + + @kern_val.setter + def kern_val(self, value: Length | None) -> None: + if value is None: + self._remove_kern() + return + kern = self.get_or_add_kern() + kern.val = value + + @property + def spacing_val(self) -> Length | None: + """Value of `w:spacing/@w:val` or |None| if not present.""" + spacing = self.spacing + if spacing is None: + return None + return spacing.val + + @spacing_val.setter + def spacing_val(self, value: Length | None) -> None: + if value is None: + self._remove_spacing() + return + spacing = self.get_or_add_spacing() + spacing.val = value + @property def sz_val(self) -> Length | None: """The value of `w:sz/@w:val` or |None| if not present.""" diff --git a/src/docx/oxml/text/hyperlink.py b/src/docx/oxml/text/hyperlink.py index 38a33ff15..a9707cb6c 100644 --- a/src/docx/oxml/text/hyperlink.py +++ b/src/docx/oxml/text/hyperlink.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING, Callable, List from docx.oxml.simpletypes import ST_OnOff, ST_String, XsdString from docx.oxml.text.run import CT_R @@ -19,6 +19,7 @@ class CT_Hyperlink(BaseOxmlElement): """`` element, containing the text and address for a hyperlink.""" + add_r: Callable[[], CT_R] r_lst: List[CT_R] rId: str | None = OptionalAttribute("r:id", XsdString) # pyright: ignore[reportAssignmentType] diff --git a/src/docx/oxml/text/paragraph.py b/src/docx/oxml/text/paragraph.py index 63e96f312..4b68a2001 100644 --- a/src/docx/oxml/text/paragraph.py +++ b/src/docx/oxml/text/paragraph.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Callable, List, cast +from docx.oxml.ns import qn from docx.oxml.parser import OxmlElement from docx.oxml.xmlchemy import BaseOxmlElement, ZeroOrMore, ZeroOrOne @@ -16,6 +17,8 @@ from docx.oxml.text.pagebreak import CT_LastRenderedPageBreak from docx.oxml.text.parfmt import CT_PPr from docx.oxml.text.run import CT_R + from docx.oxml.tracked_changes import CT_Del, CT_Ins + from docx.oxml.text.font import CT_RPr class CT_P(BaseOxmlElement): @@ -30,6 +33,52 @@ class CT_P(BaseOxmlElement): hyperlink = ZeroOrMore("w:hyperlink") r = ZeroOrMore("w:r") + def add_hyperlink( + self, rId: str | None, anchor: str | None, text: str, rPr: CT_RPr | None + ) -> CT_Hyperlink: + """Return a newly appended `CT_Hyperlink` child element. + + `rId` is the relationship id for an external URL (or None for internal links). + `anchor` is a bookmark name for internal links (or None for external links). + `text` is the visible text of the hyperlink. + `rPr` is an optional run-properties element to apply to the hyperlink run. + """ + from docx.oxml.text.hyperlink import CT_Hyperlink + + hyperlink = cast(CT_Hyperlink, OxmlElement("w:hyperlink")) + if rId is not None: + hyperlink.rId = rId + if anchor is not None: + hyperlink.anchor = anchor + hyperlink.history = True + r = hyperlink.add_r() + if rPr is not None: + r.insert(0, rPr) + r.add_t(text) + self.append(hyperlink) + return hyperlink + + def add_bookmark(self, bookmark_id: int, name: str) -> None: + """Add bookmarkStart/bookmarkEnd pair to this paragraph. + + When no specific run positions are given, the bookmark wraps the entire + paragraph content (all runs). + """ + bookmarkStart = OxmlElement( + "w:bookmarkStart", + attrs={qn("w:id"): str(bookmark_id), qn("w:name"): name}, + ) + bookmarkEnd = OxmlElement( + "w:bookmarkEnd", + attrs={qn("w:id"): str(bookmark_id)}, + ) + # -- insert bookmarkStart after pPr (or at beginning) and bookmarkEnd at end -- + if self.pPr is not None: + self.pPr.addnext(bookmarkStart) + else: + self.insert(0, bookmarkStart) + self.append(bookmarkEnd) + def add_p_before(self) -> CT_P: """Return a new `` element inserted directly prior to this one.""" new_p = cast(CT_P, OxmlElement("w:p")) @@ -101,6 +150,11 @@ def text(self): # pyright: ignore[reportIncompatibleMethodOverride] """ return "".join(e.text for e in self.xpath("w:r | w:hyperlink")) + @property + def tracked_change_elements(self) -> List[CT_Ins | CT_Del]: + """`w:ins` and `w:del` children of this paragraph, in document order.""" + return self.xpath("./w:ins | ./w:del") + def _insert_pPr(self, pPr: CT_PPr) -> CT_PPr: self.insert(0, pPr) return pPr diff --git a/src/docx/oxml/text/parfmt.py b/src/docx/oxml/text/parfmt.py index 2133686b2..5b5dac1ae 100644 --- a/src/docx/oxml/text/parfmt.py +++ b/src/docx/oxml/text/parfmt.py @@ -6,12 +6,19 @@ from docx.enum.text import ( WD_ALIGN_PARAGRAPH, + WD_BORDER_STYLE, WD_LINE_SPACING, WD_TAB_ALIGNMENT, WD_TAB_LEADER, ) from docx.oxml.shared import CT_DecimalNumber -from docx.oxml.simpletypes import ST_SignedTwipsMeasure, ST_TwipsMeasure +from docx.oxml.simpletypes import ( + ST_EighthPointMeasure, + ST_HexColor, + ST_PointMeasure, + ST_SignedTwipsMeasure, + ST_TwipsMeasure, +) from docx.oxml.xmlchemy import ( BaseOxmlElement, OneOrMore, @@ -24,6 +31,53 @@ if TYPE_CHECKING: from docx.oxml.section import CT_SectPr from docx.oxml.shared import CT_String + from docx.shared import RGBColor + + +class CT_Border(BaseOxmlElement): + """````, ````, ````, ````, ````, + ```` element. + + Defines a single paragraph border edge. + """ + + val: WD_BORDER_STYLE | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:val", WD_BORDER_STYLE + ) + sz: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:sz", ST_EighthPointMeasure + ) + space: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:space", ST_PointMeasure + ) + color: RGBColor | str | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:color", ST_HexColor + ) + + +class CT_PBdr(BaseOxmlElement): + """```` element, containing the paragraph border properties.""" + + _tag_seq = ("w:top", "w:left", "w:bottom", "w:right", "w:between", "w:bar") + top: CT_Border | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:top", successors=_tag_seq[1:] + ) + left: CT_Border | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:left", successors=_tag_seq[2:] + ) + bottom: CT_Border | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:bottom", successors=_tag_seq[3:] + ) + right: CT_Border | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:right", successors=_tag_seq[4:] + ) + between: CT_Border | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:between", successors=_tag_seq[5:] + ) + bar: CT_Border | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:bar", successors=() + ) + del _tag_seq class CT_Ind(BaseOxmlElement): @@ -55,9 +109,11 @@ class CT_PPr(BaseOxmlElement): """```` element, containing the properties for a paragraph.""" get_or_add_ind: Callable[[], CT_Ind] + get_or_add_pBdr: Callable[[], CT_PBdr] get_or_add_pStyle: Callable[[], CT_String] get_or_add_sectPr: Callable[[], CT_SectPr] _insert_sectPr: Callable[[CT_SectPr], None] + _remove_pBdr: Callable[[], None] _remove_pStyle: Callable[[], None] _remove_sectPr: Callable[[], None] @@ -107,6 +163,9 @@ class CT_PPr(BaseOxmlElement): pageBreakBefore = ZeroOrOne("w:pageBreakBefore", successors=_tag_seq[4:]) widowControl = ZeroOrOne("w:widowControl", successors=_tag_seq[6:]) numPr = ZeroOrOne("w:numPr", successors=_tag_seq[7:]) + pBdr: CT_PBdr | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "w:pBdr", successors=_tag_seq[9:] + ) tabs = ZeroOrOne("w:tabs", successors=_tag_seq[11:]) spacing = ZeroOrOne("w:spacing", successors=_tag_seq[22:]) ind: CT_Ind | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] @@ -341,12 +400,17 @@ def widowControl_val(self, value): class CT_Spacing(BaseOxmlElement): """```` element, specifying paragraph spacing attributes such as space - before and line spacing.""" + before and line spacing. + + Also used as ``w:rPr/w:spacing`` for character spacing, where only the ``w:val`` + attribute is used (type ``ST_SignedTwipsMeasure``). + """ after = OptionalAttribute("w:after", ST_TwipsMeasure) before = OptionalAttribute("w:before", ST_TwipsMeasure) line = OptionalAttribute("w:line", ST_SignedTwipsMeasure) lineRule = OptionalAttribute("w:lineRule", WD_LINE_SPACING) + val: Length | None = OptionalAttribute("w:val", ST_SignedTwipsMeasure) class CT_TabStop(BaseOxmlElement): diff --git a/src/docx/oxml/text/run.py b/src/docx/oxml/text/run.py index 7496aa616..58d939f41 100644 --- a/src/docx/oxml/text/run.py +++ b/src/docx/oxml/text/run.py @@ -2,6 +2,7 @@ from __future__ import annotations +from copy import deepcopy from typing import TYPE_CHECKING, Callable, Iterator, List, cast from docx.oxml.drawing import CT_Drawing @@ -88,6 +89,19 @@ def iter_items() -> Iterator[str | CT_Drawing | CT_LastRenderedPageBreak]: return list(iter_items()) + def insert_bookmark_end_after(self, bookmark_id: int) -> None: + """Insert a `w:bookmarkEnd` element with `bookmark_id` after this run.""" + self.addnext(OxmlElement("w:bookmarkEnd", attrs={qn("w:id"): str(bookmark_id)})) + + def insert_bookmark_start_before(self, bookmark_id: int, name: str) -> None: + """Insert a `w:bookmarkStart` element with `bookmark_id` and `name` before this run.""" + self.addprevious( + OxmlElement( + "w:bookmarkStart", + attrs={qn("w:id"): str(bookmark_id), qn("w:name"): name}, + ) + ) + def insert_comment_range_end_and_reference_below(self, comment_id: int) -> None: """Insert a `w:commentRangeEnd` and `w:commentReference` element after this run. @@ -142,10 +156,73 @@ def text(self, text: str): # pyright: ignore[reportIncompatibleMethodOverride] self.clear_content() _RunContentAppender.append_to_run_from_text(self, text) + def split_run(self, offset: int) -> CT_R: + """Split this run at character `offset`, returning the new right-hand run. + + Text content up to but not including `offset` remains in this run. A new + `w:r` element containing text from `offset` onward is created with a copy + of this run's `w:rPr` and inserted as the next sibling. The new run element + is returned. + """ + text = self.text + if offset < 0 or offset > len(text): + raise ValueError( + f"offset {offset} out of range for run text of length {len(text)}" + ) + + # -- create new run with copy of rPr -- + new_r = cast(CT_R, OxmlElement("w:r")) + rPr = self.rPr + if rPr is not None: + new_r._insert_rPr(deepcopy(rPr)) + + # -- set text on each run -- + self.text = text[:offset] + new_r.text = text[offset:] + + # -- insert new run after this one in the parent -- + self.addnext(new_r) + + return new_r + def _insert_rPr(self, rPr: CT_RPr) -> CT_RPr: self.insert(0, rPr) return rPr + def insert_footnote_reference(self, footnote_id: int) -> None: + """Append a `w:footnoteReference` element to this run. + + The run is styled with the "FootnoteReference" character style and a + `w:footnoteReference` element referencing `footnote_id` is appended. + + Should produce XML like: + + + + + + """ + rPr = self.get_or_add_rPr() + rPr.style = "FootnoteReference" + self.append(OxmlElement("w:footnoteReference", attrs={qn("w:id"): str(footnote_id)})) + + def insert_endnote_reference(self, endnote_id: int) -> None: + """Append a `w:endnoteReference` element to this run. + + The run is styled with the "EndnoteReference" character style and a + `w:endnoteReference` element referencing `endnote_id` is appended. + + Should produce XML like: + + + + + + """ + rPr = self.get_or_add_rPr() + rPr.style = "EndnoteReference" + self.append(OxmlElement("w:endnoteReference", attrs={qn("w:id"): str(endnote_id)})) + def _new_comment_reference_run(self, comment_id: int) -> CT_R: """Return a new `w:r` element with `w:commentReference` referencing `comment_id`. diff --git a/src/docx/oxml/tracked_changes.py b/src/docx/oxml/tracked_changes.py new file mode 100644 index 000000000..a7dc91f57 --- /dev/null +++ b/src/docx/oxml/tracked_changes.py @@ -0,0 +1,62 @@ +"""Custom element classes related to tracked changes (revisions).""" + +from __future__ import annotations + +import datetime as dt +from typing import TYPE_CHECKING, List + +from docx.oxml.simpletypes import ST_DateTime, ST_DecimalNumber, ST_String +from docx.oxml.xmlchemy import BaseOxmlElement, OptionalAttribute, RequiredAttribute, ZeroOrMore + +if TYPE_CHECKING: + from docx.oxml.text.run import CT_R + + +class CT_RunTrackChange(BaseOxmlElement): + """Base for `` and `` elements wrapping runs in a paragraph. + + Both share the same attribute set: `w:id`, `w:author`, and `w:date`. + """ + + r_lst: List[CT_R] + + r = ZeroOrMore("w:r", successors=()) + + id: int = RequiredAttribute("w:id", ST_DecimalNumber) # pyright: ignore[reportAssignmentType] + author: str = RequiredAttribute( # pyright: ignore[reportAssignmentType] + "w:author", ST_String + ) + date: dt.datetime | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "w:date", ST_DateTime + ) + + +class CT_Ins(CT_RunTrackChange): + """`` element, containing runs that were inserted.""" + + @property + def text(self) -> str: + """The textual content of the inserted runs.""" + return "".join(r.text for r in self.r_lst) + + +class CT_Del(CT_RunTrackChange): + """`` element, containing runs that were deleted.""" + + @property + def text(self) -> str: + """The textual content of the deleted runs. + + Deleted runs use `w:delText` elements rather than `w:t`. + """ + return "".join( + str(e) for e in self.xpath("w:r/w:delText") + ) + + +class CT_DelText(BaseOxmlElement): + """`` element, containing text in a deleted run.""" + + def __str__(self) -> str: + """Text contained in this element, the empty string if it has no content.""" + return self.text or "" diff --git a/src/docx/oxml/xmlchemy.py b/src/docx/oxml/xmlchemy.py index e2c54b392..63321ad99 100644 --- a/src/docx/oxml/xmlchemy.py +++ b/src/docx/oxml/xmlchemy.py @@ -684,12 +684,12 @@ def xml(self) -> str: """ return serialize_for_reading(self) - def xpath(self, xpath_str: str) -> Any: # pyright: ignore[reportIncompatibleMethodOverride] + def xpath(self, xpath_str: str, **kwargs: Any) -> Any: # pyright: ignore[reportIncompatibleMethodOverride] """Override of `lxml` _Element.xpath() method. Provides standard Open XML namespace mapping (`nsmap`) in centralized location. """ - return super().xpath(xpath_str, namespaces=nsmap) + return super().xpath(xpath_str, namespaces=nsmap, **kwargs) @property def _nsptag(self) -> str: diff --git a/src/docx/parts/document.py b/src/docx/parts/document.py index 4960264b1..c4f125d73 100644 --- a/src/docx/parts/document.py +++ b/src/docx/parts/document.py @@ -7,6 +7,8 @@ from docx.document import Document from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.parts.comments import CommentsPart +from docx.parts.endnotes import EndnotesPart +from docx.parts.footnotes import FootnotesPart from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.numbering import NumberingPart from docx.parts.settings import SettingsPart @@ -17,7 +19,9 @@ if TYPE_CHECKING: from docx.comments import Comments + from docx.endnotes import Endnotes from docx.enum.style import WD_STYLE_TYPE + from docx.footnotes import Footnotes from docx.opc.coreprops import CoreProperties from docx.settings import Settings from docx.styles.style import BaseStyle @@ -49,6 +53,44 @@ def comments(self) -> Comments: """|Comments| object providing access to the comments added to this document.""" return self._comments_part.comments + @property + def endnotes(self) -> Endnotes: + """|Endnotes| object providing access to the endnotes in this document.""" + return self._endnotes_part.endnotes + + @property + def _endnotes_part(self) -> EndnotesPart: + """A |EndnotesPart| providing access to the endnotes for this document. + + Creates a default endnotes part if one is not present. + """ + try: + return cast(EndnotesPart, self.part_related_by(RT.ENDNOTES)) + except KeyError: + assert self.package is not None + endnotes_part = EndnotesPart.default(self.package) + self.relate_to(endnotes_part, RT.ENDNOTES) + return endnotes_part + + @property + def footnotes(self) -> Footnotes: + """|Footnotes| object providing access to the footnotes in this document.""" + return self._footnotes_part.footnotes + + @property + def _footnotes_part(self) -> FootnotesPart: + """A |FootnotesPart| providing access to the footnotes for this document. + + Creates a default footnotes part if one is not present. + """ + try: + return cast(FootnotesPart, self.part_related_by(RT.FOOTNOTES)) + except KeyError: + assert self.package is not None + footnotes_part = FootnotesPart.default(self.package) + self.relate_to(footnotes_part, RT.FOOTNOTES) + return footnotes_part + @property def core_properties(self) -> CoreProperties: """A |CoreProperties| object providing read/write access to the core properties diff --git a/src/docx/parts/endnotes.py b/src/docx/parts/endnotes.py new file mode 100644 index 000000000..917715e70 --- /dev/null +++ b/src/docx/parts/endnotes.py @@ -0,0 +1,54 @@ +"""|EndnotesPart| and closely related objects.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, cast + +from typing_extensions import Self + +from docx.endnotes import Endnotes +from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.packuri import PackURI +from docx.oxml.endnotes import CT_Endnotes +from docx.oxml.parser import parse_xml +from docx.parts.story import StoryPart + +if TYPE_CHECKING: + from docx.package import Package + + +class EndnotesPart(StoryPart): + """Proxy for the endnotes.xml part containing endnotes for a document.""" + + def __init__( + self, partname: PackURI, content_type: str, element: CT_Endnotes, package: Package + ): + super().__init__(partname, content_type, element, package) + self._endnotes = element + + @property + def endnotes(self) -> Endnotes: + """A |Endnotes| proxy object for the `w:endnotes` root element of this part.""" + return Endnotes(self._endnotes, self) + + @property + def endnotes_element(self) -> CT_Endnotes: + """The `w:endnotes` root element of this part.""" + return self._endnotes + + @classmethod + def default(cls, package: Package) -> Self: + """A newly created endnotes part, containing separator and continuation separator.""" + partname = PackURI("/word/endnotes.xml") + content_type = CT.WML_ENDNOTES + element = cast("CT_Endnotes", parse_xml(cls._default_endnotes_xml())) + return cls(partname, content_type, element, package) + + @classmethod + def _default_endnotes_xml(cls) -> bytes: + """A byte-string containing XML for a default endnotes part.""" + path = os.path.join(os.path.split(__file__)[0], "..", "templates", "default-endnotes.xml") + with open(path, "rb") as f: + xml_bytes = f.read() + return xml_bytes diff --git a/src/docx/parts/footnotes.py b/src/docx/parts/footnotes.py new file mode 100644 index 000000000..545eba18c --- /dev/null +++ b/src/docx/parts/footnotes.py @@ -0,0 +1,54 @@ +"""|FootnotesPart| and closely related objects.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, cast + +from typing_extensions import Self + +from docx.footnotes import Footnotes +from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.packuri import PackURI +from docx.oxml.footnotes import CT_Footnotes +from docx.oxml.parser import parse_xml +from docx.parts.story import StoryPart + +if TYPE_CHECKING: + from docx.package import Package + + +class FootnotesPart(StoryPart): + """Proxy for the footnotes.xml part containing footnotes for a document.""" + + def __init__( + self, partname: PackURI, content_type: str, element: CT_Footnotes, package: Package + ): + super().__init__(partname, content_type, element, package) + self._footnotes = element + + @property + def footnotes(self) -> Footnotes: + """A |Footnotes| proxy object for the `w:footnotes` root element of this part.""" + return Footnotes(self._footnotes, self) + + @property + def footnotes_element(self) -> CT_Footnotes: + """The `w:footnotes` root element of this part.""" + return self._footnotes + + @classmethod + def default(cls, package: Package) -> Self: + """A newly created footnotes part, containing separator and continuation separator.""" + partname = PackURI("/word/footnotes.xml") + content_type = CT.WML_FOOTNOTES + element = cast("CT_Footnotes", parse_xml(cls._default_footnotes_xml())) + return cls(partname, content_type, element, package) + + @classmethod + def _default_footnotes_xml(cls) -> bytes: + """A byte-string containing XML for a default footnotes part.""" + path = os.path.join(os.path.split(__file__)[0], "..", "templates", "default-footnotes.xml") + with open(path, "rb") as f: + xml_bytes = f.read() + return xml_bytes diff --git a/src/docx/parts/story.py b/src/docx/parts/story.py index 7482c91a8..42b66045b 100644 --- a/src/docx/parts/story.py +++ b/src/docx/parts/story.py @@ -2,8 +2,10 @@ from __future__ import annotations +import io from typing import IO, TYPE_CHECKING, Tuple, cast +from docx.image.constants import MIME_TYPE from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.part import XmlPart from docx.oxml.shape import CT_Inline @@ -71,8 +73,40 @@ def new_pic_inline( rId, image = self.get_or_add_image(image_descriptor) cx, cy = image.scaled_dimensions(width, height) shape_id, filename = self.next_id, image.filename + + if image.content_type == MIME_TYPE.SVG: + return self._new_svg_pic_inline( + shape_id, rId, filename, cx, cy + ) + return CT_Inline.new_pic_inline(shape_id, rId, filename, cx, cy) + def _new_svg_pic_inline( + self, + shape_id: int, + svg_rId: str, + filename: str, + cx: Length, + cy: Length, + ) -> CT_Inline: + """Return a `wp:inline` element for an SVG image with a PNG fallback.""" + fallback_png = self._generate_svg_fallback() + fallback_stream = io.BytesIO(fallback_png) + fallback_rId, _ = self.get_or_add_image(fallback_stream) + return CT_Inline.new_svg_pic_inline( + shape_id, fallback_rId, svg_rId, filename, cx, cy + ) + + @staticmethod + def _generate_svg_fallback() -> bytes: + """Return PNG bytes to use as SVG fallback. + + Generates a minimal 1x1 transparent PNG placeholder. + """ + from docx.image.svg import generate_fallback_png + + return generate_fallback_png() + @property def next_id(self) -> int: """Next available positive integer id value in this story XML document. diff --git a/src/docx/search.py b/src/docx/search.py new file mode 100644 index 000000000..85c9f70ab --- /dev/null +++ b/src/docx/search.py @@ -0,0 +1,204 @@ +"""Search and replace functionality for python-docx documents.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, List, Tuple + +if TYPE_CHECKING: + from docx.text.paragraph import Paragraph + from docx.text.run import Run + + +class SearchMatch: + """A single match of a search term within a document. + + Provides access to the paragraph containing the match, the run indices that span the + match, and the character offsets within the reconstructed paragraph text. + """ + + def __init__( + self, + paragraph: Paragraph, + paragraph_index: int, + run_indices: List[int], + start: int, + end: int, + ): + self._paragraph = paragraph + self._paragraph_index = paragraph_index + self._run_indices = run_indices + self._start = start + self._end = end + + @property + def paragraph(self) -> Paragraph: + """The |Paragraph| containing this match.""" + return self._paragraph + + @property + def paragraph_index(self) -> int: + """Index of the paragraph in the document's paragraph list.""" + return self._paragraph_index + + @property + def run_indices(self) -> List[int]: + """Indices of runs that span this match.""" + return self._run_indices + + @property + def start(self) -> int: + """Character offset of match start in the paragraph's reconstructed text.""" + return self._start + + @property + def end(self) -> int: + """Character offset of match end in the paragraph's reconstructed text.""" + return self._end + + +def _build_char_map(runs: List[Run]) -> Tuple[str, List[Tuple[int, int]]]: + """Build full text from runs and a map from character position to (run_index, offset). + + Returns a tuple of (full_text, char_map) where char_map[i] is (run_index, + char_offset_within_run) for the i-th character in full_text. + """ + full_text = "" + char_map: List[Tuple[int, int]] = [] + for run_idx, run in enumerate(runs): + run_text = run.text + for char_offset in range(len(run_text)): + char_map.append((run_idx, char_offset)) + full_text += run_text + return full_text, char_map + + +def _compile_pattern(text: str, case_sensitive: bool, whole_word: bool) -> re.Pattern[str]: + """Compile a regex pattern for the given search text and options.""" + escaped = re.escape(text) + if whole_word: + escaped = rf"\b{escaped}\b" + flags = 0 if case_sensitive else re.IGNORECASE + return re.compile(escaped, flags) + + +def search_paragraphs( + paragraphs: List[Paragraph], + text: str, + case_sensitive: bool = True, + whole_word: bool = False, +) -> List[SearchMatch]: + """Find all occurrences of `text` across `paragraphs`. + + Returns a list of |SearchMatch| objects, one for each occurrence found. + """ + if not text: + return [] + + pattern = _compile_pattern(text, case_sensitive, whole_word) + matches: List[SearchMatch] = [] + + for para_idx, paragraph in enumerate(paragraphs): + full_text, char_map = _build_char_map(paragraph.runs) + for m in pattern.finditer(full_text): + start, end = m.start(), m.end() + run_indices = sorted({char_map[i][0] for i in range(start, end)}) + matches.append( + SearchMatch( + paragraph=paragraph, + paragraph_index=para_idx, + run_indices=run_indices, + start=start, + end=end, + ) + ) + + return matches + + +def replace_in_paragraphs( + paragraphs: List[Paragraph], + old_text: str, + new_text: str, + case_sensitive: bool = True, + whole_word: bool = False, +) -> int: + """Replace all occurrences of `old_text` with `new_text` in `paragraphs`. + + Preserves the formatting of the first character's run for each replacement. Returns + the number of replacements made. + """ + if not old_text: + return 0 + + pattern = _compile_pattern(old_text, case_sensitive, whole_word) + total_replacements = 0 + + for paragraph in paragraphs: + total_replacements += _replace_in_paragraph(paragraph, pattern, new_text) + + return total_replacements + + +def _replace_in_paragraph( + paragraph: Paragraph, pattern: re.Pattern[str], new_text: str +) -> int: + """Replace all matches of `pattern` with `new_text` in a single paragraph. + + Processes matches from right to left so that earlier character positions remain valid + as the text is modified. + """ + runs = paragraph.runs + if not runs: + return 0 + + full_text, char_map = _build_char_map(runs) + matches = list(pattern.finditer(full_text)) + if not matches: + return 0 + + # Process matches from right to left to preserve positions. + for m in reversed(matches): + _apply_replacement(runs, char_map, m.start(), m.end(), new_text) + + return len(matches) + + +def _apply_replacement( + runs: List[Run], + char_map: List[Tuple[int, int]], + match_start: int, + match_end: int, + new_text: str, +) -> None: + """Replace the text at [match_start, match_end) with `new_text` across runs. + + The formatting of the run containing the first matched character is preserved. Text + is removed from subsequent runs that were part of the match; empty runs are left in + place (their formatting may be needed by Word). + """ + first_run_idx, first_char_offset = char_map[match_start] + last_run_idx, last_char_offset = char_map[match_end - 1] + + first_run = runs[first_run_idx] + first_run_text = first_run.text + + if first_run_idx == last_run_idx: + # Match is entirely within one run. + first_run.text = ( + first_run_text[:first_char_offset] + + new_text + + first_run_text[last_char_offset + 1 :] + ) + else: + # Match spans multiple runs. Put replacement text in the first run, + # clear matched portions from the remaining runs. + first_run.text = first_run_text[:first_char_offset] + new_text + + # Clear text from fully-spanned middle runs. + for run_idx in range(first_run_idx + 1, last_run_idx): + runs[run_idx].text = "" + + # Trim the matched prefix from the last run. + last_run = runs[last_run_idx] + last_run.text = last_run.text[last_char_offset + 1 :] diff --git a/src/docx/section.py b/src/docx/section.py index 982a14370..0588909dc 100644 --- a/src/docx/section.py +++ b/src/docx/section.py @@ -2,20 +2,19 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Iterator, List, Sequence, overload +from typing import TYPE_CHECKING, Iterator, List, Sequence, Union, overload from docx.blkcntnr import BlockItemContainer from docx.enum.section import WD_HEADER_FOOTER from docx.oxml.text.paragraph import CT_P from docx.parts.hdrftr import FooterPart, HeaderPart -from docx.shared import lazyproperty from docx.table import Table from docx.text.paragraph import Paragraph if TYPE_CHECKING: from docx.enum.section import WD_ORIENTATION, WD_SECTION_START from docx.oxml.document import CT_Document - from docx.oxml.section import CT_SectPr + from docx.oxml.section import CT_Col, CT_Cols, CT_SectPr from docx.parts.document import DocumentPart from docx.parts.story import StoryPart from docx.shared import Length @@ -32,6 +31,11 @@ def __init__(self, sectPr: CT_SectPr, document_part: DocumentPart): self._sectPr = sectPr self._document_part = document_part + @property + def columns(self) -> SectionColumns: + """|SectionColumns| object providing access to column layout settings.""" + return SectionColumns(self._sectPr) + @property def bottom_margin(self) -> Length | None: """Read/write. Bottom margin for pages in this section, in EMU. @@ -94,7 +98,7 @@ def first_page_header(self) -> _Header: """ return _Header(self._sectPr, self._document_part, WD_HEADER_FOOTER.FIRST_PAGE) - @lazyproperty + @property def footer(self) -> _Footer: """|_Footer| object representing default page footer for this section. @@ -132,7 +136,7 @@ def gutter(self) -> Length | None: def gutter(self, value: int | Length | None): self._sectPr.gutter = value - @lazyproperty + @property def header(self) -> _Header: """|_Header| object representing default page header for this section. @@ -286,6 +290,122 @@ def __len__(self) -> int: return len(self._document_elm.sectPr_lst) +class Column: + """Proxy for a ```` element, representing an individual column definition.""" + + def __init__(self, col: CT_Col): + self._col = col + + @property + def space(self) -> Length | None: + """Read/write. Space after this column, in EMU. + + |None| when no space value has been specified. + """ + return self._col.space + + @space.setter + def space(self, value: Length | None): + self._col.space = value + + @property + def width(self) -> Length | None: + """Read/write. Width of this column, in EMU. + + |None| when no width has been specified. + """ + return self._col.w + + @width.setter + def width(self, value: Length | None): + self._col.w = value + + +class SectionColumns(Sequence[Column]): + """Proxy for a ```` element, providing access to column layout settings. + + Supports indexed access to individual |Column| objects when ``equal_width`` is False. + """ + + def __init__(self, sectPr: CT_SectPr): + self._sectPr = sectPr + + @overload + def __getitem__(self, key: int) -> Column: ... + + @overload + def __getitem__(self, key: slice) -> List[Column]: ... + + def __getitem__(self, key: Union[int, slice]) -> Union[Column, List[Column]]: + cols = self._sectPr.cols + col_lst = cols.col_lst if cols is not None else [] + if isinstance(key, slice): + return [Column(col) for col in col_lst[key]] + return Column(col_lst[key]) + + def __iter__(self) -> Iterator[Column]: + cols = self._sectPr.cols + if cols is not None: + for col in cols.col_lst: + yield Column(col) + + def __len__(self) -> int: + cols = self._sectPr.cols + if cols is None: + return 0 + return len(cols.col_lst) + + @property + def count(self) -> int: + """Read/write. Number of columns in this section. + + Defaults to 1 when no ``w:cols`` element is present or when ``w:num`` attribute + is not specified. + """ + cols = self._sectPr.cols + if cols is None: + return 1 + return cols.num if cols.num is not None else 1 + + @count.setter + def count(self, value: int): + cols = self._sectPr.get_or_add_cols() + cols.num = value + + @property + def equal_width(self) -> bool: + """Read/write. True when all columns have equal width. + + Defaults to True when no ``w:cols`` element is present or when ``w:equalWidth`` + attribute is not specified. + """ + cols = self._sectPr.cols + if cols is None: + return True + return cols.equalWidth if cols.equalWidth is not None else True + + @equal_width.setter + def equal_width(self, value: bool): + cols = self._sectPr.get_or_add_cols() + cols.equalWidth = value + + @property + def space(self) -> Length | None: + """Read/write. Default space between columns, in EMU. + + |None| when no ``w:cols`` element is present or no ``w:space`` attribute is set. + """ + cols = self._sectPr.cols + if cols is None: + return None + return cols.space + + @space.setter + def space(self, value: Length | None): + cols = self._sectPr.get_or_add_cols() + cols.space = value + + class _BaseHeaderFooter(BlockItemContainer): """Base class for header and footer classes.""" diff --git a/src/docx/settings.py b/src/docx/settings.py index 0a5aa2f36..31d79e0a8 100644 --- a/src/docx/settings.py +++ b/src/docx/settings.py @@ -2,6 +2,7 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING, cast from docx.shared import ElementProxy @@ -10,6 +11,7 @@ import docx.types as t from docx.oxml.settings import CT_Settings from docx.oxml.xmlchemy import BaseOxmlElement + from docx.shared import Length class Settings(ElementProxy): @@ -23,13 +25,109 @@ def __init__(self, element: BaseOxmlElement, parent: t.ProvidesXmlPart | None = self._settings = cast("CT_Settings", element) @property - def odd_and_even_pages_header_footer(self) -> bool: + def compatibility_mode(self) -> int | None: + """The target Word compatibility-mode version (e.g. 15 for Word 2013+). + + Read/write. None when no compatibility mode is specified. + """ + return self._settings.compatibilityMode + + @compatibility_mode.setter + def compatibility_mode(self, value: int | None): + self._settings.compatibilityMode = value + + @property + def default_tab_stop(self) -> Length | None: + """The default tab-stop interval for the document as a |Length| value. + + Read/write. Assign a |Length| value (e.g. ``Twips(720)``) or |None| to remove. + """ + return self._settings.defaultTabStop_val + + @default_tab_stop.setter + def default_tab_stop(self, value: int | Length | None): + self._settings.defaultTabStop_val = value + + @property + def document_protection(self) -> _DocumentProtection: + """Read-only access to document protection settings. + + Provides `.type` (str or None) and `.enabled` (bool) properties. + """ + return _DocumentProtection(self._settings) + + @property + def even_and_odd_headers(self) -> bool: """True if this document has distinct odd and even page headers and footers. Read/write. """ return self._settings.evenAndOddHeaders_val + @even_and_odd_headers.setter + def even_and_odd_headers(self, value: bool): + self._settings.evenAndOddHeaders_val = value + + @property + def odd_and_even_pages_header_footer(self) -> bool: + """True if this document has distinct odd and even page headers and footers. + + Read/write. Deprecated: use `even_and_odd_headers` instead. + """ + warnings.warn( + "odd_and_even_pages_header_footer is deprecated, use even_and_odd_headers instead", + DeprecationWarning, + stacklevel=2, + ) + return self.even_and_odd_headers + @odd_and_even_pages_header_footer.setter def odd_and_even_pages_header_footer(self, value: bool): - self._settings.evenAndOddHeaders_val = value + warnings.warn( + "odd_and_even_pages_header_footer is deprecated, use even_and_odd_headers instead", + DeprecationWarning, + stacklevel=2, + ) + self.even_and_odd_headers = value + + @property + def track_revisions(self) -> bool: + """True when revision tracking is enabled for this document. + + Read/write. + """ + return self._settings.trackRevisions_val + + @track_revisions.setter + def track_revisions(self, value: bool): + self._settings.trackRevisions_val = value + + @property + def zoom_percent(self) -> int | None: + """The zoom percentage for the document view (e.g. 100 for 100%). + + Read/write. None when no zoom is specified. + """ + return self._settings.zoom_percent + + @zoom_percent.setter + def zoom_percent(self, value: int | None): + self._settings.zoom_percent = value + + +class _DocumentProtection: + """Read-only access to document-protection settings.""" + + def __init__(self, settings: CT_Settings): + self._settings = settings + + @property + def enabled(self) -> bool: + """True when document protection is enforced.""" + return self._settings.documentProtection_enforcement + + @property + def type(self) -> str | None: + """The protection type (e.g. "readOnly", "comments", "trackedChanges", "forms") + or None if no protection is set.""" + return self._settings.documentProtection_edit diff --git a/src/docx/shape.py b/src/docx/shape.py index cd35deb35..c924ce96e 100644 --- a/src/docx/shape.py +++ b/src/docx/shape.py @@ -44,7 +44,7 @@ def __len__(self): @property def _inline_lst(self): body = self._body - xpath = "//w:p/w:r/w:drawing/wp:inline" + xpath = ".//w:p/w:r/w:drawing/wp:inline" return body.xpath(xpath) diff --git a/src/docx/styles/style.py b/src/docx/styles/style.py index aa175ea80..1d9833432 100644 --- a/src/docx/styles/style.py +++ b/src/docx/styles/style.py @@ -189,10 +189,6 @@ def font(self): return Font(self._element) -# -- just in case someone uses the old name in an extension function -- -_CharacterStyle = CharacterStyle - - class ParagraphStyle(CharacterStyle): """A paragraph style. @@ -232,10 +228,6 @@ def paragraph_format(self): return ParagraphFormat(self._element) -# -- just in case someone uses the old name in an extension function -- -_ParagraphStyle = ParagraphStyle - - class _TableStyle(ParagraphStyle): """A table style. diff --git a/src/docx/styles/styles.py b/src/docx/styles/styles.py index b05b3ebb1..f4f07beaa 100644 --- a/src/docx/styles/styles.py +++ b/src/docx/styles/styles.py @@ -58,9 +58,9 @@ def add_style(self, name, style_type, builtin=False): A builtin style can be defined by passing True for the optional `builtin` argument. """ - style_name = BabelFish.ui2internal(name) - if style_name in self: + if name in self: raise ValueError("document already contains style '%s'" % name) + style_name = BabelFish.ui2internal(name) style = self._element.add_style_of_type(style_name, style_type, builtin) return StyleFactory(style) diff --git a/src/docx/table.py b/src/docx/table.py index 545c46884..5f05ca18e 100644 --- a/src/docx/table.py +++ b/src/docx/table.py @@ -2,21 +2,31 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING, Iterator, cast, overload from typing_extensions import TypeAlias from docx.blkcntnr import BlockItemContainer from docx.enum.style import WD_STYLE_TYPE -from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT +from docx.enum.table import WD_BORDER_STYLE, WD_CELL_VERTICAL_ALIGNMENT, WD_SHADING_PATTERN from docx.oxml.simpletypes import ST_Merge from docx.oxml.table import CT_TblGridCol -from docx.shared import Inches, Parented, StoryChild, lazyproperty +from docx.shared import Emu, Inches, Parented, Pt, RGBColor, StoryChild, lazyproperty if TYPE_CHECKING: import docx.types as t from docx.enum.table import WD_ROW_HEIGHT_RULE, WD_TABLE_ALIGNMENT, WD_TABLE_DIRECTION - from docx.oxml.table import CT_Row, CT_Tbl, CT_TblPr, CT_Tc + from docx.oxml.table import ( + CT_Border, + CT_Row, + CT_Shd, + CT_Tbl, + CT_TblBorders, + CT_TblPr, + CT_Tc, + CT_TcBorders, + ) from docx.shared import Length from docx.styles.style import ( ParagraphStyle, @@ -34,6 +44,18 @@ def __init__(self, tbl: CT_Tbl, parent: t.ProvidesStoryPart): self._element = tbl self._tbl = tbl + def delete(self) -> None: + """Remove this table from the document. + + The table element is removed from its parent. After calling this method, + this |Table| object is "defunct" and should not be used further. + """ + tbl = self._tbl + parent = tbl.getparent() + if parent is None: + return + parent.remove(tbl) + def add_column(self, width: Length): """Return a |_Column| object of `width`, newly added rightmost to the table.""" tblGrid = self._tbl.tblGrid @@ -82,6 +104,58 @@ def autofit(self) -> bool: def autofit(self, value: bool): self._tblPr.autofit = value + @property + def borders(self) -> TableBorders: + """Read-only. |TableBorders| object providing access to table border properties. + + Always returns a |TableBorders| object; setting border properties on it will + create the required XML elements on demand. + """ + return TableBorders(self._tbl) + + def set_borders( + self, + top: bool = False, + bottom: bool = False, + left: bool = False, + right: bool = False, + inside_h: bool = False, + inside_v: bool = False, + style: WD_BORDER_STYLE = WD_BORDER_STYLE.SINGLE, + width: Length | None = None, + color: RGBColor | None = None, + ) -> None: + """Convenience method to set multiple table borders at once. + + Each boolean parameter controls whether that border edge is enabled. + Enabled borders use the specified `style`, `width`, and `color`. + Disabled borders are set to ``WD_BORDER_STYLE.NONE``. + + Example for APA 7 tables (horizontal-only borders):: + + table.set_borders(top=True, bottom=True, inside_h=True) + """ + border_width = width if width is not None else Pt(0.5) + border_color = color if color is not None else RGBColor(0, 0, 0) + borders = self.borders + for attr, enabled in [ + ("top", top), + ("bottom", bottom), + ("left", left), + ("right", right), + ("inside_h", inside_h), + ("inside_v", inside_v), + ]: + border = getattr(borders, attr) + if enabled: + border.style = style + border.width = border_width + border.color = border_color + else: + border.style = WD_BORDER_STYLE.NONE + border.width = None + border.color = None + def cell(self, row_idx: int, col_idx: int) -> _Cell: """|_Cell| at `row_idx`, `col_idx` intersection. @@ -106,6 +180,11 @@ def row_cells(self, row_idx: int) -> list[_Cell]: Sequence of cells in the row at `row_idx` in this table. """ + warnings.warn( + "Table.row_cells() is deprecated, use table.rows[row_idx].cells instead", + DeprecationWarning, + stacklevel=2, + ) column_count = self._column_count start = row_idx * column_count end = start + column_count @@ -210,6 +289,15 @@ def add_paragraph(self, text: str = "", style: str | ParagraphStyle | None = Non """ return super(_Cell, self).add_paragraph(text, style) + @property + def borders(self) -> CellBorders: + """Read-only. |CellBorders| object providing access to cell border properties. + + Always returns a |CellBorders| object; setting border properties on it will + create the required XML elements on demand. + """ + return CellBorders(self._tc) + def add_table( # pyright: ignore[reportIncompatibleMethodOverride] self, rows: int, cols: int ) -> Table: @@ -283,6 +371,15 @@ def text(self, text: str): r = p.add_r() r.text = text + @property + def shading(self) -> CellShading: + """Read-only. |CellShading| object providing access to shading properties. + + Always returns a |CellShading| object; setting shading properties on it will + create the required XML elements on demand. + """ + return CellShading(self._tc) + @property def vertical_alignment(self): """Member of :ref:`WdCellVerticalAlignment` or None. @@ -311,6 +408,295 @@ def width(self, value: Length): self._tc.width = value +class CellShading: + """Provides access to shading properties for a table cell. + + Accessed via ``_Cell.shading``. + """ + + def __init__(self, tc: CT_Tc): + self._tc = tc + + @property + def fill_color(self) -> RGBColor | None: + """The background fill color as an |RGBColor| value, or |None| if not set. + + Note: returns |None| when the fill attribute is ``"auto"`` (foreground-dependent). + """ + shd = self._shd + if shd is None: + return None + fill = shd.fill + if fill is None or not isinstance(fill, RGBColor): + return None + return fill + + @fill_color.setter + def fill_color(self, value: RGBColor | None): + if value is None: + tcPr = self._tc.tcPr + if tcPr is not None and tcPr.shd is not None: + tcPr.shd.fill = None + return + shd = self._get_or_add_shd() + shd.fill = value + + @property + def pattern(self) -> WD_SHADING_PATTERN | None: + """The shading pattern as a |WD_SHADING_PATTERN| value, or |None| if not set.""" + shd = self._shd + if shd is None: + return None + return shd.val + + @pattern.setter + def pattern(self, value: WD_SHADING_PATTERN | None): + if value is None: + tcPr = self._tc.tcPr + if tcPr is not None and tcPr.shd is not None: + tcPr.shd.val = None + return + shd = self._get_or_add_shd() + shd.val = value + + @property + def _shd(self) -> CT_Shd | None: + tcPr = self._tc.tcPr + if tcPr is None: + return None + return tcPr.shd + + def _get_or_add_shd(self) -> CT_Shd: + tcPr = self._tc.get_or_add_tcPr() + shd = tcPr.get_or_add_shd() + if shd.val is None: + shd.val = WD_SHADING_PATTERN.CLEAR + return shd + + +class BorderElement: + """Provides access to properties of a single border edge. + + Wraps a ``CT_Border`` element (e.g. ````, ````). + """ + + def __init__(self, border: CT_Border | None, get_or_add: Callable[[], CT_Border]): + self._border = border + self._get_or_add = get_or_add + + @property + def style(self) -> WD_BORDER_STYLE | None: + """The border style as a |WD_BORDER_STYLE| value, or |None| if not set.""" + border = self._border + if border is None: + return None + return border.val + + @style.setter + def style(self, value: WD_BORDER_STYLE | None): + if value is None: + border = self._border + if border is not None: + border.val = None + return + border = self._get_or_add() + self._border = border + border.val = value + + @property + def width(self) -> Length | None: + """The border width as an EMU |Length| value, or |None| if not set. + + The ``w:sz`` attribute stores the width in eighths of a point. + """ + border = self._border + if border is None: + return None + sz = border.sz + if sz is None: + return None + return Pt(sz / 8.0) + + @width.setter + def width(self, value: Length | None): + if value is None: + border = self._border + if border is not None: + border.sz = None + return + border = self._get_or_add() + self._border = border + border.sz = int(Emu(value).pt * 8) + + @property + def color(self) -> RGBColor | None: + """The border color as an |RGBColor| value, or |None| if not set.""" + border = self._border + if border is None: + return None + color = border.color + if color is None or not isinstance(color, RGBColor): + return None + return color + + @color.setter + def color(self, value: RGBColor | None): + if value is None: + border = self._border + if border is not None: + border.color = None + return + border = self._get_or_add() + self._border = border + border.color = value + + @property + def space(self) -> int | None: + """The border spacing in points, or |None| if not set.""" + border = self._border + if border is None: + return None + return border.space + + @space.setter + def space(self, value: int | None): + if value is None: + border = self._border + if border is not None: + border.space = None + return + border = self._get_or_add() + self._border = border + border.space = value + + +class TableBorders: + """Provides access to border properties for a table. + + Accessed via ``Table.borders``. + """ + + def __init__(self, tbl: CT_Tbl): + self._tbl = tbl + + @property + def top(self) -> BorderElement: + """The top border of the table.""" + tblBorders = self._tblBorders + return BorderElement( + tblBorders.top if tblBorders is not None else None, + lambda: self._get_or_add_tblBorders().get_or_add_top(), + ) + + @property + def bottom(self) -> BorderElement: + """The bottom border of the table.""" + tblBorders = self._tblBorders + return BorderElement( + tblBorders.bottom if tblBorders is not None else None, + lambda: self._get_or_add_tblBorders().get_or_add_bottom(), + ) + + @property + def left(self) -> BorderElement: + """The left border of the table.""" + tblBorders = self._tblBorders + return BorderElement( + tblBorders.left if tblBorders is not None else None, + lambda: self._get_or_add_tblBorders().get_or_add_left(), + ) + + @property + def right(self) -> BorderElement: + """The right border of the table.""" + tblBorders = self._tblBorders + return BorderElement( + tblBorders.right if tblBorders is not None else None, + lambda: self._get_or_add_tblBorders().get_or_add_right(), + ) + + @property + def inside_h(self) -> BorderElement: + """The inside horizontal border of the table.""" + tblBorders = self._tblBorders + return BorderElement( + tblBorders.insideH if tblBorders is not None else None, + lambda: self._get_or_add_tblBorders().get_or_add_insideH(), + ) + + @property + def inside_v(self) -> BorderElement: + """The inside vertical border of the table.""" + tblBorders = self._tblBorders + return BorderElement( + tblBorders.insideV if tblBorders is not None else None, + lambda: self._get_or_add_tblBorders().get_or_add_insideV(), + ) + + @property + def _tblBorders(self) -> CT_TblBorders | None: + return self._tbl.tblPr.tblBorders + + def _get_or_add_tblBorders(self) -> CT_TblBorders: + return self._tbl.tblPr.get_or_add_tblBorders() + + +class CellBorders: + """Provides access to border properties for a table cell. + + Accessed via ``_Cell.borders``. + """ + + def __init__(self, tc: CT_Tc): + self._tc = tc + + @property + def top(self) -> BorderElement: + """The top border of the cell.""" + tcBorders = self._tcBorders + return BorderElement( + tcBorders.top if tcBorders is not None else None, + lambda: self._get_or_add_tcBorders().get_or_add_top(), + ) + + @property + def bottom(self) -> BorderElement: + """The bottom border of the cell.""" + tcBorders = self._tcBorders + return BorderElement( + tcBorders.bottom if tcBorders is not None else None, + lambda: self._get_or_add_tcBorders().get_or_add_bottom(), + ) + + @property + def left(self) -> BorderElement: + """The left border of the cell.""" + tcBorders = self._tcBorders + return BorderElement( + tcBorders.left if tcBorders is not None else None, + lambda: self._get_or_add_tcBorders().get_or_add_left(), + ) + + @property + def right(self) -> BorderElement: + """The right border of the cell.""" + tcBorders = self._tcBorders + return BorderElement( + tcBorders.right if tcBorders is not None else None, + lambda: self._get_or_add_tcBorders().get_or_add_right(), + ) + + @property + def _tcBorders(self) -> CT_TcBorders | None: + tcPr = self._tc.tcPr + if tcPr is None: + return None + return tcPr.tcBorders + + def _get_or_add_tcBorders(self) -> CT_TcBorders: + return self._tc.get_or_add_tcPr().get_or_add_tcBorders() + + class _Column(Parented): """Table column.""" @@ -392,6 +778,19 @@ def __init__(self, tr: CT_Row, parent: TableParent): self._parent = parent self._tr = self._element = tr + @property + def allow_break_across_pages(self) -> bool: + """True when row can be split across page boundaries. + + When set to |False|, the entire row is moved to the next page rather than + allowing it to be split across a page break. Defaults to |True|. + """ + return self._tr.allow_break_across_pages + + @allow_break_across_pages.setter + def allow_break_across_pages(self, value: bool): + self._tr.allow_break_across_pages = value + @property def cells(self) -> tuple[_Cell, ...]: """Sequence of |_Cell| instances corresponding to cells in this row. @@ -477,6 +876,18 @@ def height(self) -> Length | None: no explicit height is set.""" return self._tr.trHeight_val + @property + def is_header(self) -> bool: + """True when this row is a header row that repeats at the top of each page. + + Read/write. Only the first N consecutive rows can be header rows (Word limitation). + """ + return self._tr.is_header + + @is_header.setter + def is_header(self, value: bool) -> None: + self._tr.is_header = value + @height.setter def height(self, value: Length | None): self._tr.trHeight_val = value diff --git a/src/docx/templates/default-endnotes.xml b/src/docx/templates/default-endnotes.xml new file mode 100644 index 000000000..7bb180dd3 --- /dev/null +++ b/src/docx/templates/default-endnotes.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/docx/templates/default-footnotes.xml b/src/docx/templates/default-footnotes.xml new file mode 100644 index 000000000..27b2f07f0 --- /dev/null +++ b/src/docx/templates/default-footnotes.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/docx/text/font.py b/src/docx/text/font.py index 0439f4547..dab01fd3f 100644 --- a/src/docx/text/font.py +++ b/src/docx/text/font.py @@ -35,6 +35,24 @@ def all_caps(self) -> bool | None: def all_caps(self, value: bool | None) -> None: self._set_bool_prop("caps", value) + @property + def character_spacing(self) -> Length | None: + """Read/write. + + |Length| value specifying the spacing between characters. Positive values expand + the spacing, negative values condense it. |None| indicates the value is inherited + from the style hierarchy. + """ + rPr = self._element.rPr + if rPr is None: + return None + return rPr.spacing_val + + @character_spacing.setter + def character_spacing(self, value: int | Length | None) -> None: + rPr = self._element.get_or_add_rPr() + rPr.spacing_val = None if value is None else Emu(value) + @property def bold(self) -> bool | None: """Read/write. @@ -156,6 +174,24 @@ def italic(self) -> bool | None: def italic(self, value: bool | None) -> None: self._set_bool_prop("i", value) + @property + def kerning(self) -> Length | None: + """Read/write. + + |Length| value specifying the minimum font size for which kerning is automatically + adjusted. |None| indicates kerning is not specified (inherited from style + hierarchy). + """ + rPr = self._element.rPr + if rPr is None: + return None + return rPr.kern_val + + @kerning.setter + def kerning(self, value: int | Length | None) -> None: + rPr = self._element.get_or_add_rPr() + rPr.kern_val = None if value is None else Emu(value) + @property def imprint(self) -> bool | None: """Read/write tri-state value. @@ -181,6 +217,53 @@ def math(self) -> bool | None: def math(self, value: bool | None) -> None: self._set_bool_prop("oMath", value) + @property + def name_cs(self) -> str | None: + """The Complex Script typeface name for this |Font|. + + Causes Complex Script text it controls to appear in the named font. |None| + indicates the typeface is inherited from the style hierarchy. + """ + rPr = self._element.rPr + if rPr is None: + return None + return rPr.rFonts_cs + + @name_cs.setter + def name_cs(self, value: str | None) -> None: + rPr = self._element.get_or_add_rPr() + rPr.rFonts_cs = value + + @property + def name_east_asia(self) -> str | None: + """The East Asian typeface name for this |Font|. + + Causes East Asian text it controls to appear in the named font. |None| indicates + the typeface is inherited from the style hierarchy. Alias for `name_far_east`. + """ + return self.name_far_east + + @name_east_asia.setter + def name_east_asia(self, value: str | None) -> None: + self.name_far_east = value + + @property + def name_far_east(self) -> str | None: + """The East Asian typeface name for this |Font|. + + Causes East Asian (CJK) text it controls to appear in the named font. |None| + indicates the typeface is inherited from the style hierarchy. + """ + rPr = self._element.rPr + if rPr is None: + return None + return rPr.rFonts_eastAsia + + @name_far_east.setter + def name_far_east(self, value: str | None) -> None: + rPr = self._element.get_or_add_rPr() + rPr.rFonts_eastAsia = value + @property def name(self) -> str | None: """The typeface name for this |Font|. diff --git a/src/docx/text/paragraph.py b/src/docx/text/paragraph.py index 234ea66cb..089874638 100644 --- a/src/docx/text/paragraph.py +++ b/src/docx/text/paragraph.py @@ -4,19 +4,28 @@ from typing import TYPE_CHECKING, Iterator, List, cast +from docx.drawing import Drawing +from docx.enum.section import WD_SECTION_START from docx.enum.style import WD_STYLE_TYPE +from docx.enum.text import WD_BREAK +from docx.opc.constants import RELATIONSHIP_TYPE as RT +from docx.oxml.drawing import CT_Drawing from docx.oxml.text.run import CT_R from docx.shared import StoryChild from docx.styles.style import ParagraphStyle from docx.text.hyperlink import Hyperlink from docx.text.pagebreak import RenderedPageBreak from docx.text.parfmt import ParagraphFormat +from docx.tracked_changes import TrackedChange from docx.text.run import Run if TYPE_CHECKING: import docx.types as t + from docx.bookmarks import Bookmark from docx.enum.text import WD_PARAGRAPH_ALIGNMENT + from docx.oxml.document import CT_Body from docx.oxml.text.paragraph import CT_P + from docx.section import Section from docx.styles.style import CharacterStyle @@ -27,6 +36,100 @@ def __init__(self, p: CT_P, parent: t.ProvidesStoryPart): super(Paragraph, self).__init__(parent) self._p = self._element = p + def add_bookmark( + self, + name: str, + start_run: Run | None = None, + end_run: Run | None = None, + ) -> Bookmark: + """Add a bookmark to this paragraph and return it. + + `name` is the bookmark name, which must be unique within the document. + + When `start_run` and `end_run` are both |None|, the bookmark wraps the entire + paragraph content. When `start_run` is provided, the bookmark starts before that + run. When `end_run` is provided, the bookmark ends after that run. When only + `start_run` is provided, `end_run` defaults to `start_run`. + """ + from docx.bookmarks import Bookmark + + body = self._get_body() + bookmark_id = self._next_bookmark_id(body) + + if start_run is None and end_run is None: + self._p.add_bookmark(bookmark_id, name) + else: + if start_run is None: + start_run = end_run + if end_run is None: + end_run = start_run + assert start_run is not None + assert end_run is not None + start_run._r.insert_bookmark_start_before(bookmark_id, name) + end_run._r.insert_bookmark_end_after(bookmark_id) + + bookmarkStart = self._p.xpath(f".//w:bookmarkStart[@w:id='{bookmark_id}']") + return Bookmark(bookmarkStart[0], body) + + def _get_body(self) -> CT_Body: + """Return the w:body ancestor element.""" + from docx.oxml.document import CT_Body + + ancestor = self._p.getparent() + while ancestor is not None and not isinstance(ancestor, CT_Body): + ancestor = ancestor.getparent() + if ancestor is None: + raise ValueError("paragraph is not contained in a document body") + return ancestor + + @staticmethod + def _next_bookmark_id(body) -> int: + """Return the next available bookmark ID in the document body.""" + used_ids = [int(x) for x in body.xpath(".//w:bookmarkStart/@w:id")] + return max(used_ids, default=-1) + 1 + + def add_hyperlink( + self, + url: str | None = None, + text: str | None = None, + style: str | CharacterStyle | None = "Hyperlink", + anchor: str | None = None, + ) -> Hyperlink: + """Append a hyperlink to this paragraph and return a |Hyperlink| object. + + `url` is the target URL for an external hyperlink (e.g. "https://example.com"). + `text` is the visible link text; defaults to `url` or `anchor` when not provided. + `style` is the character style for the hyperlink run, defaulting to "Hyperlink". + `anchor` is a bookmark name for an internal document link. + + Either `url` or `anchor` must be provided, but not both. + """ + if url is None and anchor is None: + raise ValueError("Either url or anchor must be provided") + if url is not None and anchor is not None: + raise ValueError("Only one of url or anchor may be provided, not both") + + display_text = text if text is not None else (url or anchor or "") + + rId = None + if url is not None: + rId = self.part.relate_to(url, RT.HYPERLINK, is_external=True) + + rPr = None + if style is not None: + from docx.oxml.ns import qn + from docx.oxml.parser import OxmlElement + + style_id = self.part.get_style_id(style, WD_STYLE_TYPE.CHARACTER) + if style_id is not None: + rPr = OxmlElement("w:rPr") + rStyle = OxmlElement("w:rStyle") + rStyle.set(qn("w:val"), style_id) + rPr.append(rStyle) + + hyperlink_elm = self._p.add_hyperlink(rId, anchor, display_text, rPr) + return Hyperlink(hyperlink_elm, self) + def add_run(self, text: str | None = None, style: str | CharacterStyle | None = None) -> Run: """Append run containing `text` and having character-style `style`. @@ -43,6 +146,12 @@ def add_run(self, text: str | None = None, style: str | CharacterStyle | None = run.style = style return run + def add_page_break(self) -> Paragraph: + """Append a page-break run to this paragraph and return self.""" + run = self.add_run() + run.add_break(WD_BREAK.PAGE) + return self + @property def alignment(self) -> WD_PARAGRAPH_ALIGNMENT | None: """A member of the :ref:`WdParagraphAlignment` enumeration specifying the @@ -66,16 +175,91 @@ def clear(self): self._p.clear_content() return self + def delete(self) -> None: + """Remove this paragraph from the document. + + The paragraph element is removed from its parent. After calling this method, + this |Paragraph| object is "defunct" and should not be used further. + """ + p = self._p + parent = p.getparent() + if parent is None: + return + parent.remove(p) + + def clear_page_breaks(self) -> None: + """Remove all ```` elements from this paragraph. + + If a run contains only a page break and no other content, the entire run is + removed. If a run contains other content alongside the page break, only the + ```` element is removed. Does nothing when no page breaks are present. + """ + for br in self._p.xpath('.//w:br[@w:type="page"]'): + r = br.getparent() + r.remove(br) + # --- remove the run if it's now empty (no child elements and no text) --- + if len(r) == 0 and not r.text: + r.getparent().remove(r) + + @property + def has_section_break(self) -> bool: + """``True`` if this paragraph contains a section break (```` in its + ````).""" + pPr = self._p.pPr + if pPr is None: + return False + return pPr.sectPr is not None + @property def contains_page_break(self) -> bool: """`True` when one or more rendered page-breaks occur in this paragraph.""" return bool(self._p.lastRenderedPageBreaks) + @property + def has_page_break(self) -> bool: + """`True` if this paragraph contains at least one ````.""" + return bool(self._p.xpath('.//w:br[@w:type="page"]')) + + @property + def drawings(self) -> List[Drawing]: + """A |Drawing| instance for each `` element in this paragraph.""" + return [ + Drawing(cast(CT_Drawing, d), self) + for d in self._p.xpath(".//w:drawing") + ] + @property def hyperlinks(self) -> List[Hyperlink]: """A |Hyperlink| instance for each hyperlink in this paragraph.""" return [Hyperlink(hyperlink, self) for hyperlink in self._p.hyperlink_lst] + def insert_section_break( + self, start_type: WD_SECTION_START = WD_SECTION_START.NEW_PAGE + ) -> Section: + """Insert a section break in this paragraph and return the new |Section|. + + `start_type` is a member of :ref:`WdSectionStart` and defaults to + ``WD_SECTION.NEW_PAGE``. If this paragraph already contains a section break, + its type is replaced rather than a new one being added. + """ + from docx.section import Section as SectionCls + + pPr = self._p.get_or_add_pPr() + sectPr = pPr.get_or_add_sectPr() + sectPr.start_type = start_type + return SectionCls(sectPr, self.part) + + def remove_section_break(self) -> None: + """Remove the section break from this paragraph, if one is present. + + Calling this on a paragraph that has no section break is a no-op. + """ + pPr = self._p.pPr + if pPr is None: + return + if pPr.sectPr is not None: + pPr._remove_sectPr() + def insert_paragraph_before( self, text: str | None = None, style: str | ParagraphStyle | None = None ) -> Paragraph: @@ -146,6 +330,12 @@ def style(self, style_or_name: str | ParagraphStyle | None): style_id = self.part.get_style_id(style_or_name, WD_STYLE_TYPE.PARAGRAPH) self._p.style = style_id + @property + def tracked_changes(self) -> List[TrackedChange]: + """A list of |TrackedChange| objects for each insertion or deletion in this + paragraph.""" + return [TrackedChange(tc) for tc in self._p.tracked_change_elements] + @property def text(self) -> str: """The textual content of this paragraph. diff --git a/src/docx/text/parfmt.py b/src/docx/text/parfmt.py index ea374373b..e9757248c 100644 --- a/src/docx/text/parfmt.py +++ b/src/docx/text/parfmt.py @@ -1,14 +1,28 @@ """Paragraph-related proxy types.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from docx.enum.text import WD_LINE_SPACING -from docx.shared import ElementProxy, Emu, Length, Pt, Twips, lazyproperty +from docx.shared import ElementProxy, Emu, Length, Pt, RGBColor, Twips, lazyproperty from docx.text.tabstops import TabStops +if TYPE_CHECKING: + from docx.enum.text import WD_BORDER_STYLE + from docx.oxml.text.parfmt import CT_Border + class ParagraphFormat(ElementProxy): """Provides access to paragraph formatting such as justification, indentation, line spacing, space before and after, and widow/orphan control.""" + @property + def borders(self) -> ParagraphBorders: + """|ParagraphBorders| object providing access to the border settings for this + paragraph.""" + return ParagraphBorders(self._element) + @property def alignment(self): """A member of the :ref:`WdParagraphAlignment` enumeration specifying the @@ -284,3 +298,153 @@ def _line_spacing_rule(line, lineRule): if line == Twips(480): return WD_LINE_SPACING.DOUBLE return lineRule + + +class ParagraphBorders: + """Provides access to the border settings for a paragraph. + + Accessed via the :attr:`ParagraphFormat.borders` property. + """ + + def __init__(self, element: object): + self._element = element + + @property + def top(self) -> Border: + """The |Border| object for the top edge of the paragraph.""" + return Border(self._element, "top") + + @property + def bottom(self) -> Border: + """The |Border| object for the bottom edge of the paragraph.""" + return Border(self._element, "bottom") + + @property + def left(self) -> Border: + """The |Border| object for the left edge of the paragraph.""" + return Border(self._element, "left") + + @property + def right(self) -> Border: + """The |Border| object for the right edge of the paragraph.""" + return Border(self._element, "right") + + @property + def between(self) -> Border: + """The |Border| object for the border between identical paragraphs.""" + return Border(self._element, "between") + + @property + def bar(self) -> Border: + """The |Border| object for the bar border of the paragraph.""" + return Border(self._element, "bar") + + +class Border: + """Provides access to a single border edge of a paragraph. + + Accessed via the properties of |ParagraphBorders|, e.g. + ``paragraph_format.borders.bottom``. + """ + + def __init__(self, element: object, side: str): + self._element = element + self._side = side + + @property + def _border_elm(self) -> CT_Border | None: + pPr = self._element.pPr # type: ignore[attr-defined] + if pPr is None: + return None + pBdr = pPr.pBdr + if pBdr is None: + return None + return getattr(pBdr, self._side) + + def _get_or_add_border_elm(self) -> CT_Border: + pPr = self._element.get_or_add_pPr() # type: ignore[attr-defined] + pBdr = pPr.get_or_add_pBdr() + return getattr(pBdr, f"get_or_add_{self._side}")() + + @property + def style(self) -> WD_BORDER_STYLE | None: + """The border style as a member of :ref:`WdBorderStyle`, or |None| if no border + is defined.""" + border = self._border_elm + if border is None: + return None + return border.val + + @style.setter + def style(self, value: WD_BORDER_STYLE | None) -> None: + if value is None: + pPr = self._element.pPr # type: ignore[attr-defined] + if pPr is not None: + pBdr = pPr.pBdr + if pBdr is not None: + remove_fn = getattr(pBdr, f"_remove_{self._side}", None) + if remove_fn is not None: + remove_fn() + return + self._get_or_add_border_elm().val = value + + @property + def width(self) -> Length | None: + """The border width as a |Length| value, or |None| if not defined. + + Stored in the XML as eighths of a point in the ``w:sz`` attribute. + """ + border = self._border_elm + if border is None: + return None + return border.sz + + @width.setter + def width(self, value: Length | None) -> None: + if value is None: + border = self._border_elm + if border is not None: + border.sz = None + return + self._get_or_add_border_elm().sz = value + + @property + def color(self) -> RGBColor | None: + """|RGBColor| value of the border color, or |None| if not defined. + + An ``"auto"`` value in the XML is returned as |None|. + """ + border = self._border_elm + if border is None: + return None + color = border.color + if isinstance(color, str): + return None + return color + + @color.setter + def color(self, value: RGBColor | None) -> None: + if value is None: + border = self._border_elm + if border is not None: + border.color = None + return + self._get_or_add_border_elm().color = value + + @property + def space(self) -> Length | None: + """The spacing between the border and paragraph text as a |Length| value, or + |None| if not defined.""" + border = self._border_elm + if border is None: + return None + return border.space + + @space.setter + def space(self, value: Length | None) -> None: + if value is None: + border = self._border_elm + if border is not None: + border.space = None + return + self._get_or_add_border_elm().space = value diff --git a/src/docx/text/run.py b/src/docx/text/run.py index 57ea31fa4..aa9580cc2 100644 --- a/src/docx/text/run.py +++ b/src/docx/text/run.py @@ -117,6 +117,18 @@ def clear(self): self._r.clear_content() return self + def delete(self) -> None: + """Remove this run from its parent paragraph. + + The run element is removed from its parent. After calling this method, + this |Run| object is "defunct" and should not be used further. + """ + r = self._r + parent = r.getparent() + if parent is None: + return + parent.remove(r) + @property def contains_page_break(self) -> bool: """`True` when one or more rendered page-breaks occur in this run. @@ -185,6 +197,17 @@ def mark_comment_range(self, last_run: Run, comment_id: int) -> None: # -- `last_run` last_run._r.insert_comment_range_end_and_reference_below(comment_id) + def split(self, offset: int) -> tuple[Run, Run]: + """Return (left_run, right_run) after splitting this run at character `offset`. + + Text before `offset` stays in this run and text from `offset` onward moves + to a new run inserted immediately after this one. Both runs share the same + character formatting (`w:rPr`). + """ + new_r = self._r.split_run(offset) + right_run = Run(new_r, self._parent) + return self, right_run + @property def style(self) -> CharacterStyle: """Read/write. diff --git a/src/docx/tracked_changes.py b/src/docx/tracked_changes.py new file mode 100644 index 000000000..d1192092c --- /dev/null +++ b/src/docx/tracked_changes.py @@ -0,0 +1,43 @@ +"""Proxy objects for tracked changes (revision marks) in a document.""" + +from __future__ import annotations + +import datetime as dt +from typing import TYPE_CHECKING + +from docx.shared import ElementProxy + +if TYPE_CHECKING: + from docx.oxml.tracked_changes import CT_RunTrackChange + + +class TrackedChange(ElementProxy): + """Proxy for a single tracked change (insertion or deletion) in a paragraph. + + Wraps a `` or `` element that contains one or more runs. + """ + + def __init__(self, element: CT_RunTrackChange): + super().__init__(element) + + @property + def author(self) -> str: + """The author who made this change.""" + return self._element.author + + @property + def date(self) -> dt.datetime | None: + """The date and time when this change was made, or |None| if not recorded.""" + return self._element.date + + @property + def text(self) -> str: + """The textual content of this tracked change.""" + return self._element.text + + @property + def type(self) -> str: + """The type of this tracked change, either ``"insertion"`` or ``"deletion"``.""" + from docx.oxml.tracked_changes import CT_Ins + + return "insertion" if isinstance(self._element, CT_Ins) else "deletion" diff --git a/tests/conftest.py b/tests/conftest.py index 2abfcc969..82afc864f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,15 @@ from __future__ import annotations +import os +import tempfile from typing import TYPE_CHECKING import pytest +from docx import Document +from docx.document import Document as DocumentCls + if TYPE_CHECKING: from docx import types as t from docx.parts.story import StoryPart @@ -19,3 +24,19 @@ def part(self) -> StoryPart: raise NotImplementedError return ProvidesStoryPart() + + +@pytest.fixture +def tmp_docx_path(): + """Yield a temporary file path for .docx output; cleaned up after test.""" + fd, path = tempfile.mkstemp(suffix=".docx") + os.close(fd) + yield path + if os.path.exists(path): + os.unlink(path) + + +@pytest.fixture +def blank_document() -> DocumentCls: + """Return a new blank Document for use in tests.""" + return Document() diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 000000000..228640d95 --- /dev/null +++ b/tests/helpers/__init__.py @@ -0,0 +1,16 @@ +"""Test helpers for validating python-docx output across multiple layers. + +Provides utilities for XML structure validation, OOXML schema validation, +round-trip testing, and reference file comparison. +""" + +from tests.helpers.roundtrip import assert_round_trip +from tests.helpers.validate import extract_xml_part, validate_ooxml_structure +from tests.helpers.xmlparse import parse_docx_xml + +__all__ = [ + "assert_round_trip", + "extract_xml_part", + "parse_docx_xml", + "validate_ooxml_structure", +] diff --git a/tests/helpers/libreoffice.py b/tests/helpers/libreoffice.py new file mode 100644 index 000000000..f3dcff54d --- /dev/null +++ b/tests/helpers/libreoffice.py @@ -0,0 +1,101 @@ +"""LibreOffice headless validation for .docx files. + +Converts .docx files to PDF using LibreOffice in headless mode. If the conversion +fails, it indicates the file is malformed or contains unsupported content. + +This validation layer is optional and requires LibreOffice to be installed. Tests +using this helper should be marked with `@pytest.mark.libreoffice`. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile + + +class LibreOfficeNotAvailable(RuntimeError): + """Raised when LibreOffice is not installed or not on PATH.""" + + +class LibreOfficeConversionError(RuntimeError): + """Raised when LibreOffice fails to convert a .docx file.""" + + +def is_libreoffice_available() -> bool: + """Return True if LibreOffice is available on the system PATH.""" + return shutil.which("libreoffice") is not None + + +def validate_with_libreoffice( + docx_path: str, timeout: int = 60, outdir: str | None = None +) -> tuple[str, str]: + """Validate a .docx file by converting it to PDF with LibreOffice headless. + + Returns a (pdf_path, outdir) tuple on success. The caller is responsible for + cleaning up `outdir` (e.g. via `shutil.rmtree(outdir)`). + + Raises LibreOfficeConversionError if the conversion fails. + Raises LibreOfficeNotAvailable if LibreOffice is not installed. + + Args: + docx_path: Path to the .docx file to validate. + timeout: Maximum seconds to wait for conversion (default 60). + outdir: Optional output directory. A temporary directory is created if None. + """ + if not is_libreoffice_available(): + raise LibreOfficeNotAvailable( + "LibreOffice is not installed. Install with: " + "sudo apt-get install libreoffice-writer" + ) + + created_outdir = outdir is None + if outdir is None: + outdir = tempfile.mkdtemp(prefix="docx_lo_validate_") + + try: + result = subprocess.run( + [ + "libreoffice", + "--headless", + "--convert-to", + "pdf", + "--outdir", + outdir, + docx_path, + ], + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + if created_outdir: + shutil.rmtree(outdir, ignore_errors=True) + raise LibreOfficeConversionError( + f"LibreOffice conversion timed out after {timeout}s for {docx_path}" + ) + + if result.returncode != 0: + if created_outdir: + shutil.rmtree(outdir, ignore_errors=True) + raise LibreOfficeConversionError( + f"LibreOffice conversion failed (exit code {result.returncode}):\n" + f"stdout: {result.stdout}\n" + f"stderr: {result.stderr}" + ) + + # Find the generated PDF + basename = os.path.splitext(os.path.basename(docx_path))[0] + pdf_path = os.path.join(outdir, f"{basename}.pdf") + + if not os.path.exists(pdf_path): + if created_outdir: + shutil.rmtree(outdir, ignore_errors=True) + raise LibreOfficeConversionError( + f"LibreOffice conversion produced no output PDF for {docx_path}.\n" + f"stdout: {result.stdout}\n" + f"stderr: {result.stderr}" + ) + + return pdf_path, outdir diff --git a/tests/helpers/refcmp.py b/tests/helpers/refcmp.py new file mode 100644 index 000000000..158ea8a80 --- /dev/null +++ b/tests/helpers/refcmp.py @@ -0,0 +1,112 @@ +"""Reference file comparison helpers. + +Provides utilities for comparing python-docx output against reference .docx files +created in Microsoft Word. This ensures python-docx can correctly read files produced +by Word and that its output is structurally compatible. +""" + +from __future__ import annotations + +import os + +from lxml import etree + +from tests.helpers.xmlparse import parse_docx_xml + +_REF_DOCS_DIR = os.path.join(os.path.dirname(__file__), "..", "ref-docs") + + +def ref_docx_path(name: str) -> str: + """Return the absolute path to a reference .docx file by name (without extension).""" + return os.path.join(_REF_DOCS_DIR, f"{name}.docx") + + +def ref_docx_exists(name: str) -> bool: + """Return True if a reference .docx file with the given name exists.""" + return os.path.exists(ref_docx_path(name)) + + +def compare_xml_structure( + actual_path: str, + reference_path: str, + part_name: str, + ignore_attrs: set[str] | None = None, +) -> list[str]: + """Compare the XML structure of a part between two .docx files. + + Returns a list of differences. An empty list means the structures match. + Only compares element tags and specified attributes — text content and + element ordering are compared, but whitespace differences are ignored. + + `ignore_attrs` is a set of attribute names (in Clark notation) to exclude + from comparison. This is useful for attributes like `w:id` that may differ + between files but are not structurally significant. + """ + actual_elem = parse_docx_xml(actual_path, part_name) + ref_elem = parse_docx_xml(reference_path, part_name) + + if actual_elem is None and ref_elem is None: + return [] + if actual_elem is None: + return [f"Part '{part_name}' missing in actual file"] + if ref_elem is None: + return [f"Part '{part_name}' missing in reference file"] + + ignore = ignore_attrs or set() + differences: list[str] = [] + _compare_elements(actual_elem, ref_elem, "", ignore, differences) + return differences + + +def _compare_elements( + actual: etree._Element, + reference: etree._Element, + path: str, + ignore_attrs: set[str], + differences: list[str], +) -> None: + """Recursively compare two XML elements for structural equivalence.""" + current_path = f"{path}/{_local_tag(actual)}" + + # -- Compare tags -- + if actual.tag != reference.tag: + differences.append(f"{current_path}: tag mismatch: '{actual.tag}' vs '{reference.tag}'") + return + + # -- Compare attributes (excluding ignored ones) -- + actual_attrs = {k: v for k, v in actual.attrib.items() if k not in ignore_attrs} + ref_attrs = {k: v for k, v in reference.attrib.items() if k not in ignore_attrs} + if actual_attrs != ref_attrs: + differences.append( + f"{current_path}: attribute mismatch: {actual_attrs} vs {ref_attrs}" + ) + + # -- Compare text content (stripped) -- + actual_text = (actual.text or "").strip() + ref_text = (reference.text or "").strip() + if actual_text != ref_text: + differences.append( + f"{current_path}: text mismatch: '{actual_text}' vs '{ref_text}'" + ) + + # -- Compare children -- + actual_children = list(actual) + ref_children = list(reference) + + if len(actual_children) != len(ref_children): + differences.append( + f"{current_path}: child count mismatch: " + f"{len(actual_children)} vs {len(ref_children)}" + ) + return + + for a_child, r_child in zip(actual_children, ref_children): + _compare_elements(a_child, r_child, current_path, ignore_attrs, differences) + + +def _local_tag(elem: etree._Element) -> str: + """Return just the local part of an element's tag (strips namespace).""" + tag = elem.tag + if isinstance(tag, str) and tag.startswith("{"): + return tag.split("}", 1)[1] + return str(tag) diff --git a/tests/helpers/roundtrip.py b/tests/helpers/roundtrip.py new file mode 100644 index 000000000..555cab23d --- /dev/null +++ b/tests/helpers/roundtrip.py @@ -0,0 +1,63 @@ +"""Round-trip testing helpers for python-docx. + +Provides utilities for the write-save-reopen-assert pattern used to verify that +python-docx can correctly round-trip document content. +""" + +from __future__ import annotations + +import os +import tempfile +from typing import Callable, TypeVar + +from docx import Document +from docx.document import Document as DocumentCls + +T = TypeVar("T") + + +def assert_round_trip( + create_fn: Callable[[DocumentCls], T], + assert_fn: Callable[[DocumentCls, T], None], +) -> None: + """Create a document, save it, re-open it, and run assertions. + + `create_fn` receives a blank Document and should populate it with the content + under test. It may return any value that will be passed to `assert_fn` as + context (e.g. expected values). + + `assert_fn` receives the re-opened Document and the context value returned by + `create_fn`, and should assert that the content survived the round trip. + + The temporary file is automatically cleaned up. + """ + fd, path = tempfile.mkstemp(suffix=".docx") + os.close(fd) + + try: + # -- create and save -- + doc = Document() + context = create_fn(doc) + doc.save(path) + + # -- re-open and assert -- + doc2 = Document(path) + assert_fn(doc2, context) + finally: + os.unlink(path) + + +def save_and_reopen(doc: DocumentCls) -> DocumentCls: + """Save a document to a temp file and re-open it, returning the new Document. + + This is a simpler alternative to `assert_round_trip` when you need more control + over the test flow. The temporary file is cleaned up automatically. + """ + fd, path = tempfile.mkstemp(suffix=".docx") + os.close(fd) + + try: + doc.save(path) + return Document(path) + finally: + os.unlink(path) diff --git a/tests/helpers/schema.py b/tests/helpers/schema.py new file mode 100644 index 000000000..1c7093464 --- /dev/null +++ b/tests/helpers/schema.py @@ -0,0 +1,114 @@ +"""OOXML schema validation using lxml.etree.XMLSchema. + +Validates individual XML parts against XSD schemas derived from ECMA-376. +The schemas are simplified subsets focusing on the elements python-docx produces. + +For full schema validation, the complete ECMA-376 XSD files can be downloaded from: +https://www.ecma-international.org/publications-and-standards/standards/ecma-376/ + +This module provides a practical alternative that validates the most important +structural constraints without requiring the full (very large) schema set. +""" + +from __future__ import annotations + +import os +import zipfile +from typing import Optional + +from lxml import etree + +_SCHEMAS_DIR = os.path.join(os.path.dirname(__file__), "schemas") + +# -- OOXML namespace URIs -- +WML_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types" + +# -- Namespace map for xpath queries -- +OOXML_NSMAP = { + "w": WML_NS, + "r": REL_NS, + "pr": PKG_REL_NS, + "ct": CT_NS, +} + + +class SchemaValidationResult: + """Result of validating an XML part against a schema.""" + + def __init__(self, is_valid: bool, errors: list[str]): + self.is_valid = is_valid + self.errors = errors + + def __bool__(self) -> bool: + return self.is_valid + + def __repr__(self) -> str: + if self.is_valid: + return "SchemaValidationResult(valid)" + return f"SchemaValidationResult(invalid, {len(self.errors)} errors)" + + +def validate_part_xml( + xml_bytes: bytes, + schema: etree.XMLSchema, +) -> SchemaValidationResult: + """Validate XML bytes against the provided lxml XMLSchema. + + Returns a SchemaValidationResult with is_valid=True if the XML is valid, + or is_valid=False with a list of error messages otherwise. + """ + try: + doc = etree.fromstring(xml_bytes) + except etree.XMLSyntaxError as e: + return SchemaValidationResult(False, [f"XML syntax error: {e}"]) + + is_valid = schema.validate(doc) + errors = [str(e) for e in schema.error_log] if not is_valid else [] + return SchemaValidationResult(is_valid, errors) + + +def load_schema(schema_path: str) -> etree.XMLSchema: + """Load an XSD schema from a file path.""" + with open(schema_path, "rb") as f: + schema_doc = etree.parse(f) + return etree.XMLSchema(schema_doc) + + +def load_bundled_schema(name: str) -> Optional[etree.XMLSchema]: + """Load a bundled XSD schema by name. + + Returns None if the schema file does not exist (schemas are optional and may + need to be downloaded separately). + """ + path = os.path.join(_SCHEMAS_DIR, f"{name}.xsd") + if not os.path.exists(path): + return None + return load_schema(path) + + +def validate_docx_xml_parts(docx_path: str) -> dict[str, SchemaValidationResult]: + """Validate all XML parts in a .docx file for well-formedness. + + This is a lighter check that ensures every XML part in the archive is at least + well-formed XML. For schema validation of specific parts, use `validate_part_xml` + with an appropriate schema. + + Returns a dict mapping part names to their validation results. + """ + results: dict[str, SchemaValidationResult] = {} + + with zipfile.ZipFile(docx_path) as zf: + for name in zf.namelist(): + if not (name.endswith(".xml") or name.endswith(".rels")): + continue + xml_bytes = zf.read(name) + try: + etree.fromstring(xml_bytes) + results[name] = SchemaValidationResult(True, []) + except etree.XMLSyntaxError as e: + results[name] = SchemaValidationResult(False, [f"XML syntax error: {e}"]) + + return results diff --git a/tests/helpers/schemas/README.md b/tests/helpers/schemas/README.md new file mode 100644 index 000000000..934c24960 --- /dev/null +++ b/tests/helpers/schemas/README.md @@ -0,0 +1,24 @@ +# OOXML Schema Files + +This directory contains XSD schema files for validating OOXML XML parts. + +## Bundled Schemas + +- `wml-comments.xsd` — Simplified schema for `word/comments.xml` validation. + +## Full ECMA-376 Schemas + +For comprehensive schema validation, download the full XSD schemas from ECMA: + + https://www.ecma-international.org/publications-and-standards/standards/ecma-376/ + +The relevant files are in Part 4 (Transitional Migration Features) of the standard. +Place the downloaded `.xsd` files in this directory and use `load_schema()` from +`tests/helpers/schema.py` to load them. + +## How Bundled Schemas Work + +The bundled schemas are simplified subsets of the full ECMA-376 schemas. They validate +the most important structural constraints for elements that python-docx produces, without +requiring the complete (very large) schema set. They use `processContents="lax"` for +child elements to allow content that goes beyond what the simplified schema defines. diff --git a/tests/helpers/schemas/wml-comments.xsd b/tests/helpers/schemas/wml-comments.xsd new file mode 100644 index 000000000..4eff8f6d9 --- /dev/null +++ b/tests/helpers/schemas/wml-comments.xsd @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/helpers/validate.py b/tests/helpers/validate.py new file mode 100644 index 000000000..3c0b16c09 --- /dev/null +++ b/tests/helpers/validate.py @@ -0,0 +1,208 @@ +"""OOXML structure and schema validation helpers for .docx files.""" + +from __future__ import annotations + +import os +import zipfile +from typing import Sequence + +from lxml import etree + +from tests.helpers.xmlparse import parse_docx_xml + +# -- Namespaces used in OOXML documents ------------------------------------------------ + +_CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types" +_RELS_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +_WML_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + + +class OoxmlValidationError(Exception): + """Raised when OOXML structural validation fails.""" + + +def extract_xml_part(docx_path: str, part_name: str) -> etree._Element: + """Extract and parse an XML part from a .docx, raising if it does not exist. + + This is a convenience wrapper around `parse_docx_xml` that raises rather than + returning None when the part is missing. + """ + element = parse_docx_xml(docx_path, part_name) + if element is None: + raise OoxmlValidationError(f"Part '{part_name}' not found in {docx_path}") + return element + + +def validate_ooxml_structure(docx_path: str) -> list[str]: + """Validate the structural integrity of a .docx file. + + Returns a list of validation error messages. An empty list means the file is + structurally valid. Checks include: + + - The file is a valid ZIP archive. + - `[Content_Types].xml` exists and is well-formed XML. + - Every Override in `[Content_Types].xml` references a part that exists. + - `_rels/.rels` exists and is well-formed XML. + - `word/document.xml` exists and has a `w:document` root element. + - All relationship targets in `word/_rels/document.xml.rels` exist in the archive. + - All XML parts referenced are well-formed XML. + """ + errors: list[str] = [] + + # -- Check that it's a valid zip ------------------------------------------------- + if not zipfile.is_zipfile(docx_path): + return [f"{docx_path} is not a valid ZIP file"] + + with zipfile.ZipFile(docx_path) as zf: + names = set(zf.namelist()) + + # -- [Content_Types].xml ----------------------------------------------------- + if "[Content_Types].xml" not in names: + errors.append("Missing [Content_Types].xml") + else: + ct_elem = _parse_zip_xml(zf, "[Content_Types].xml", errors) + if ct_elem is not None: + _check_content_types_overrides(ct_elem, names, errors) + + # -- _rels/.rels ------------------------------------------------------------- + if "_rels/.rels" not in names: + errors.append("Missing _rels/.rels") + else: + _parse_zip_xml(zf, "_rels/.rels", errors) + + # -- word/document.xml ------------------------------------------------------- + if "word/document.xml" not in names: + errors.append("Missing word/document.xml") + else: + doc_elem = _parse_zip_xml(zf, "word/document.xml", errors) + if doc_elem is not None: + _check_root_tag(doc_elem, f"{{{_WML_NS}}}document", "word/document.xml", errors) + + # -- word/_rels/document.xml.rels -------------------------------------------- + doc_rels_path = "word/_rels/document.xml.rels" + if doc_rels_path in names: + rels_elem = _parse_zip_xml(zf, doc_rels_path, errors) + if rels_elem is not None: + _check_relationship_targets(rels_elem, names, errors) + + # -- Validate all XML parts are well-formed ---------------------------------- + already_parsed = {"[Content_Types].xml", "_rels/.rels", "word/document.xml", doc_rels_path} + for name in names: + if name in already_parsed: + continue + if name.endswith(".xml") or name.endswith(".rels"): + _parse_zip_xml(zf, name, errors) + + return errors + + +def validate_content_type_present(docx_path: str, content_type: str) -> bool: + """Return True if `content_type` is registered in [Content_Types].xml.""" + ct_elem = extract_xml_part(docx_path, "[Content_Types].xml") + for override in ct_elem.findall(f"{{{_CONTENT_TYPES_NS}}}Override"): + if override.get("ContentType") == content_type: + return True + for default in ct_elem.findall(f"{{{_CONTENT_TYPES_NS}}}Default"): + if default.get("ContentType") == content_type: + return True + return False + + +def validate_relationship_present( + docx_path: str, + rel_type: str, + rels_part: str = "word/_rels/document.xml.rels", +) -> bool: + """Return True if a relationship of `rel_type` exists in the specified rels part.""" + rels_elem = parse_docx_xml(docx_path, rels_part) + if rels_elem is None: + return False + for rel in rels_elem.findall(f"{{{_RELS_NS}}}Relationship"): + if rel.get("Type") == rel_type: + return True + return False + + +def validate_elements_present( + docx_path: str, + part_name: str, + xpath: str, + namespaces: dict[str, str] | None = None, + min_count: int = 1, +) -> list[etree._Element]: + """Assert that at least `min_count` elements matching `xpath` exist in `part_name`. + + Returns the matching elements. Raises OoxmlValidationError if the count is below + `min_count`. + """ + element = extract_xml_part(docx_path, part_name) + ns = namespaces or {"w": _WML_NS} + matches = element.xpath(xpath, namespaces=ns) + if not isinstance(matches, list): + matches = [matches] + if len(matches) < min_count: + raise OoxmlValidationError( + f"Expected at least {min_count} elements matching '{xpath}' in " + f"'{part_name}', found {len(matches)}" + ) + return matches + + +# -- internal helpers ---------------------------------------------------------------- + + +def _parse_zip_xml( + zf: zipfile.ZipFile, name: str, errors: list[str] +) -> etree._Element | None: + """Parse an XML file from the zip, appending to errors on failure.""" + try: + return etree.fromstring(zf.read(name)) + except etree.XMLSyntaxError as e: + errors.append(f"Malformed XML in {name}: {e}") + return None + + +def _check_content_types_overrides( + ct_elem: etree._Element, archive_names: set[str], errors: list[str] +) -> None: + """Verify every Override PartName in [Content_Types].xml has a matching archive entry.""" + for override in ct_elem.findall(f"{{{_CONTENT_TYPES_NS}}}Override"): + part_name = override.get("PartName", "") + # PartName starts with "/" in the XML, but zip entries don't + zip_name = part_name.lstrip("/") + if zip_name not in archive_names: + errors.append( + f"[Content_Types].xml Override references missing part: {part_name}" + ) + + +def _check_root_tag( + elem: etree._Element, expected_tag: str, part_name: str, errors: list[str] +) -> None: + """Verify an element has the expected root tag.""" + if elem.tag != expected_tag: + errors.append( + f"{part_name}: expected root tag '{expected_tag}', got '{elem.tag}'" + ) + + +def _check_relationship_targets( + rels_elem: etree._Element, archive_names: set[str], errors: list[str] +) -> None: + """Verify relationship targets exist in the archive (for internal targets only).""" + for rel in rels_elem.findall(f"{{{_RELS_NS}}}Relationship"): + target_mode = rel.get("TargetMode", "Internal") + if target_mode == "External": + continue + target = rel.get("Target", "") + # Relationship targets are relative to the source part's directory + if target.startswith("/"): + zip_path = target.lstrip("/") + else: + zip_path = f"word/{target}" + # Normalize parent-directory references (e.g. "word/../customXml/item1.xml") + zip_path = os.path.normpath(zip_path).replace("\\", "/") + if zip_path not in archive_names: + errors.append( + f"Relationship target '{target}' not found in archive (expected '{zip_path}')" + ) diff --git a/tests/helpers/xmlparse.py b/tests/helpers/xmlparse.py new file mode 100644 index 000000000..e57d65df6 --- /dev/null +++ b/tests/helpers/xmlparse.py @@ -0,0 +1,31 @@ +"""Helpers for extracting and parsing XML from .docx files.""" + +from __future__ import annotations + +import zipfile +from typing import Optional + +from lxml import etree + + +def parse_docx_xml(docx_path: str, part_name: str) -> Optional[etree._Element]: + """Extract and parse an XML part from a .docx file. + + Returns the parsed lxml Element for the specified part, or None if the part + does not exist in the archive. + + Args: + docx_path: Path to the .docx file. + part_name: The part name within the zip (e.g. "word/comments.xml"). + """ + with zipfile.ZipFile(docx_path) as zf: + if part_name not in zf.namelist(): + return None + xml_bytes = zf.read(part_name) + return etree.fromstring(xml_bytes) + + +def list_docx_parts(docx_path: str) -> list[str]: + """Return a list of all part names in a .docx file.""" + with zipfile.ZipFile(docx_path) as zf: + return zf.namelist() diff --git a/tests/image/test_svg.py b/tests/image/test_svg.py new file mode 100644 index 000000000..937139974 --- /dev/null +++ b/tests/image/test_svg.py @@ -0,0 +1,132 @@ +"""Unit test suite for docx.image.svg module.""" + +import io + +import pytest + +from docx.image.constants import MIME_TYPE +from docx.image.image import _ImageHeaderFactory +from docx.image.svg import Svg, generate_fallback_png, is_svg_stream + + +class DescribeSvg: + def it_can_construct_from_a_stream(self): + svg_bytes = ( + b'' + b"" + ) + stream = io.BytesIO(svg_bytes) + svg = Svg.from_stream(stream) + assert svg.px_width == 200 + assert svg.px_height == 100 + assert svg.content_type == MIME_TYPE.SVG + assert svg.default_ext == "svg" + + def it_parses_dimensions_from_width_and_height_attrs(self): + svg_bytes = ( + b'' + b"" + ) + stream = io.BytesIO(svg_bytes) + svg = Svg.from_stream(stream) + assert svg.px_width == 300 + assert svg.px_height == 200 + + def it_parses_dimensions_from_viewBox_when_no_width_height(self): + svg_bytes = ( + b'' + b"" + ) + stream = io.BytesIO(svg_bytes) + svg = Svg.from_stream(stream) + assert svg.px_width == 400 + assert svg.px_height == 300 + + def it_parses_width_and_height_with_units(self): + svg_bytes = ( + b'' + b"" + ) + stream = io.BytesIO(svg_bytes) + svg = Svg.from_stream(stream) + assert svg.px_width == 192 # 2 * 96 + assert svg.px_height == 96 # 1 * 96 + + def it_uses_default_dimensions_when_no_size_info(self): + svg_bytes = ( + b'' + b"" + ) + stream = io.BytesIO(svg_bytes) + svg = Svg.from_stream(stream) + assert svg.px_width == 300 + assert svg.px_height == 150 + + def it_uses_96_dpi(self): + svg_bytes = ( + b'' + b"" + ) + stream = io.BytesIO(svg_bytes) + svg = Svg.from_stream(stream) + assert svg.horz_dpi == 96 + assert svg.vert_dpi == 96 + + +class Describe_is_svg_stream: + def it_returns_True_for_an_svg_stream(self): + svg_bytes = b'' + stream = io.BytesIO(svg_bytes) + assert is_svg_stream(stream) is True + + def it_returns_True_for_svg_with_xml_declaration(self): + svg_bytes = ( + b'' + b'' + ) + stream = io.BytesIO(svg_bytes) + assert is_svg_stream(stream) is True + + def it_returns_True_for_svg_with_BOM(self): + svg_bytes = ( + b"\xef\xbb\xbf" + b'' + ) + stream = io.BytesIO(svg_bytes) + assert is_svg_stream(stream) is True + + def it_returns_False_for_a_non_svg_stream(self): + stream = io.BytesIO(b"not an svg file at all") + assert is_svg_stream(stream) is False + + def it_returns_False_for_non_svg_xml(self): + stream = io.BytesIO(b'') + assert is_svg_stream(stream) is False + + +class Describe_generate_fallback_png: + def it_generates_a_valid_png(self): + png_bytes = generate_fallback_png() + assert png_bytes[:8] == b"\x89PNG\r\n\x1a\n" + assert len(png_bytes) > 8 + + +class Describe_ImageHeaderFactory_SVG: + def it_returns_Svg_for_an_svg_stream(self): + svg_bytes = ( + b'' + b"" + ) + stream = io.BytesIO(svg_bytes) + image_header = _ImageHeaderFactory(stream) + assert isinstance(image_header, Svg) + + def it_returns_Svg_for_svg_with_xml_declaration(self): + svg_bytes = ( + b'\n' + b'' + b"" + ) + stream = io.BytesIO(svg_bytes) + image_header = _ImageHeaderFactory(stream) + assert isinstance(image_header, Svg) diff --git a/tests/opc/test_phys_pkg.py b/tests/opc/test_phys_pkg.py index 6de0d868b..cd4681fa1 100644 --- a/tests/opc/test_phys_pkg.py +++ b/tests/opc/test_phys_pkg.py @@ -52,6 +52,11 @@ def it_returns_none_when_part_has_no_rels_xml(self, dir_reader): rels_xml = dir_reader.rels_xml_for(partname) assert rels_xml is None + def it_raises_on_path_traversal(self, dir_reader): + pack_uri = PackURI("/../../../etc/passwd") + with pytest.raises(ValueError, match="resolves outside package directory"): + dir_reader.blob_for(pack_uri) + # fixtures --------------------------------------------- @pytest.fixture diff --git a/tests/oxml/test_bookmarks.py b/tests/oxml/test_bookmarks.py new file mode 100644 index 000000000..6b55445b9 --- /dev/null +++ b/tests/oxml/test_bookmarks.py @@ -0,0 +1,31 @@ +# pyright: reportPrivateUsage=false + +"""Unit-test suite for `docx.oxml.bookmarks` module.""" + +from __future__ import annotations + +from typing import cast + +from docx.oxml.bookmarks import CT_BookmarkEnd, CT_BookmarkStart + +from ..unitutil.cxml import element + + +class DescribeCT_BookmarkStart: + """Unit-test suite for `docx.oxml.bookmarks.CT_BookmarkStart`.""" + + def it_knows_its_id(self): + bookmarkStart = cast(CT_BookmarkStart, element("w:bookmarkStart{w:id=7,w:name=bm1}")) + assert bookmarkStart.id == 7 + + def it_knows_its_name(self): + bookmarkStart = cast(CT_BookmarkStart, element("w:bookmarkStart{w:id=7,w:name=bm1}")) + assert bookmarkStart.name == "bm1" + + +class DescribeCT_BookmarkEnd: + """Unit-test suite for `docx.oxml.bookmarks.CT_BookmarkEnd`.""" + + def it_knows_its_id(self): + bookmarkEnd = cast(CT_BookmarkEnd, element("w:bookmarkEnd{w:id=7}")) + assert bookmarkEnd.id == 7 diff --git a/tests/oxml/test_comments.py b/tests/oxml/test_comments.py index 8fc116144..19e2aab5a 100644 --- a/tests/oxml/test_comments.py +++ b/tests/oxml/test_comments.py @@ -8,7 +8,7 @@ import pytest -from docx.oxml.comments import CT_Comments +from docx.oxml.comments import CT_Comment, CT_Comments from ..unitutil.cxml import element @@ -29,3 +29,86 @@ class DescribeCT_Comments: def it_finds_the_next_available_comment_id_to_help(self, cxml: str, expected_value: int): comments_elm = cast(CT_Comments, element(cxml)) assert comments_elm._next_available_comment_id() == expected_value + + def it_can_add_a_comment_with_a_paraId(self): + comments_elm = cast(CT_Comments, element("w:comments")) + + comment = comments_elm.add_comment() + + assert comment.paraId is not None + assert len(comment.paraId) == 8 + # -- paraId should be a hex string -- + int(comment.paraId, 16) + + def it_generates_unique_paraIds(self): + comments_elm = cast(CT_Comments, element("w:comments")) + + comment1 = comments_elm.add_comment() + comment2 = comments_elm.add_comment() + + assert comment1.paraId != comment2.paraId + + def it_can_add_a_reply_comment(self): + comments_elm = cast(CT_Comments, element("w:comments")) + parent = comments_elm.add_comment() + parent_para_id = parent.paraId + assert parent_para_id is not None + + reply = comments_elm.add_reply(parent_para_id) + + assert reply.paraIdParent == parent_para_id + assert reply.paraId is not None + assert reply.paraId != parent_para_id + assert reply.id != parent.id + + def it_can_find_replies_for_a_comment(self): + comments_elm = cast(CT_Comments, element("w:comments")) + parent = comments_elm.add_comment() + parent_para_id = parent.paraId + assert parent_para_id is not None + reply1 = comments_elm.add_reply(parent_para_id) + reply2 = comments_elm.add_reply(parent_para_id) + # -- add an unrelated comment to make sure it's not included -- + comments_elm.add_comment() + + replies = comments_elm.get_replies_for(parent_para_id) + + assert len(replies) == 2 + assert replies[0] is reply1 + assert replies[1] is reply2 + + def but_it_returns_empty_list_when_no_replies(self): + comments_elm = cast(CT_Comments, element("w:comments")) + parent = comments_elm.add_comment() + parent_para_id = parent.paraId + assert parent_para_id is not None + + replies = comments_elm.get_replies_for(parent_para_id) + + assert replies == [] + + +class DescribeCT_Comment: + """Unit-test suite for `docx.oxml.comments.CT_Comment`.""" + + def it_can_get_and_set_paraId(self): + comment_elm = cast(CT_Comment, element("w:comment{w:id=1}")) + + assert comment_elm.paraId is None + + comment_elm.paraId = "AABB0011" + assert comment_elm.paraId == "AABB0011" + + comment_elm.paraId = None + assert comment_elm.paraId is None + + def it_can_get_and_set_paraIdParent(self): + comment_elm = cast(CT_Comment, element("w:comment{w:id=1}")) + + assert comment_elm.paraIdParent is None + + comment_elm.paraIdParent = "CCDD2233" + assert comment_elm.paraIdParent == "CCDD2233" + + comment_elm.paraIdParent = None + assert comment_elm.paraIdParent is None diff --git a/tests/oxml/test_drawing.py b/tests/oxml/test_drawing.py new file mode 100644 index 000000000..862da0323 --- /dev/null +++ b/tests/oxml/test_drawing.py @@ -0,0 +1,65 @@ +# pyright: reportPrivateUsage=false + +"""Unit test suite for the docx.oxml.drawing module.""" + +from __future__ import annotations + +from typing import cast + +from docx.oxml.drawing import CT_Drawing, CT_TxbxContent + +from ..unitutil.cxml import element + + +class DescribeCT_Drawing: + """Unit test suite for `docx.oxml.drawing.CT_Drawing` objects.""" + + def it_provides_access_to_txbxContent_descendants(self): + drawing = cast( + CT_Drawing, + element( + "w:drawing/wp:inline/a:graphic/a:graphicData" + "/wps:wsp/wps:txbx/w:txbxContent/w:p" + ), + ) + + txbx_contents = drawing.txbxContent_lst + + assert len(txbx_contents) == 1 + assert isinstance(txbx_contents[0], CT_TxbxContent) + + def it_returns_empty_list_when_no_txbxContent(self): + drawing = cast( + CT_Drawing, + element("w:drawing/wp:inline/a:graphic/a:graphicData/pic:pic"), + ) + + assert drawing.txbxContent_lst == [] + + +class DescribeCT_TxbxContent: + """Unit test suite for `docx.oxml.drawing.CT_TxbxContent` objects.""" + + def it_provides_access_to_its_paragraph_children(self): + txbxContent = cast( + CT_TxbxContent, + element("w:txbxContent/(w:p,w:p)"), + ) + + assert len(txbxContent.p_lst) == 2 + + def it_can_get_concatenated_text(self): + txbxContent = cast( + CT_TxbxContent, + element('w:txbxContent/(w:p/w:r/w:t"Hello",w:p/w:r/w:t"World")'), + ) + + assert txbxContent.text == "Hello\nWorld" + + def it_returns_empty_string_when_no_text(self): + txbxContent = cast( + CT_TxbxContent, + element("w:txbxContent/w:p"), + ) + + assert txbxContent.text == "" diff --git a/tests/oxml/test_endnotes.py b/tests/oxml/test_endnotes.py new file mode 100644 index 000000000..8094eba6b --- /dev/null +++ b/tests/oxml/test_endnotes.py @@ -0,0 +1,119 @@ +"""Unit test suite for the docx.oxml.endnotes module.""" + +from __future__ import annotations + +from typing import cast + +from docx.oxml.endnotes import CT_Endnote, CT_Endnotes +from docx.oxml.ns import qn + +from ..unitutil.cxml import element + + +class DescribeCT_Endnotes: + """Unit test suite for `docx.oxml.endnotes.CT_Endnotes` objects.""" + + def it_provides_access_to_its_endnote_children(self): + endnotes = cast( + CT_Endnotes, + element("w:endnotes/(w:endnote{w:id=0},w:endnote{w:id=1})"), + ) + + assert len(endnotes.endnote_lst) == 2 + + def it_can_add_an_endnote(self): + endnotes = cast( + CT_Endnotes, + element( + "w:endnotes/(w:endnote{w:id=0,w:type=separator}" + ",w:endnote{w:id=1,w:type=continuationSeparator})" + ), + ) + + endnote = endnotes.add_endnote() + + assert endnote.id == 2 + # -- the endnote has a paragraph with EndnoteText style -- + assert len(endnote.p_lst) == 1 + p = endnote.p_lst[0] + assert p.style == "EndnoteText" + # -- the paragraph has a run with EndnoteReference style and endnoteRef -- + assert len(p.r_lst) == 1 + r = p.r_lst[0] + assert r.style == "EndnoteReference" + assert r[-1].tag == qn("w:endnoteRef") + + def it_assigns_sequential_ids_to_added_endnotes(self): + endnotes = cast( + CT_Endnotes, + element( + "w:endnotes/(w:endnote{w:id=0,w:type=separator}" + ",w:endnote{w:id=1,w:type=continuationSeparator})" + ), + ) + + en1 = endnotes.add_endnote() + en2 = endnotes.add_endnote() + + assert en1.id == 2 + assert en2.id == 3 + + def it_skips_used_ids_when_assigning(self): + endnotes = cast( + CT_Endnotes, + element( + "w:endnotes/(w:endnote{w:id=0,w:type=separator}" + ",w:endnote{w:id=1,w:type=continuationSeparator}" + ",w:endnote{w:id=2})" + ), + ) + + endnote = endnotes.add_endnote() + + assert endnote.id == 3 + + +class DescribeCT_Endnote: + """Unit test suite for `docx.oxml.endnotes.CT_Endnote` objects.""" + + def it_provides_access_to_its_id(self): + endnote = cast(CT_Endnote, element("w:endnote{w:id=42}")) + + assert endnote.id == 42 + + def it_provides_access_to_its_type(self): + endnote = cast(CT_Endnote, element("w:endnote{w:id=0,w:type=separator}")) + + assert endnote.type == "separator" + + def it_returns_None_for_type_when_not_present(self): + endnote = cast(CT_Endnote, element("w:endnote{w:id=2}")) + + assert endnote.type is None + + def it_can_clear_its_content(self): + endnote = cast( + CT_Endnote, + element('w:endnote{w:id=2}/(w:p/w:r/w:t"Para one",w:p/w:r/w:t"Para two")'), + ) + assert len(endnote.p_lst) == 2 + + endnote.clear_content() + + assert len(endnote.p_lst) == 1 + p = endnote.p_lst[0] + assert p.style == "EndnoteText" + # -- the paragraph has an endnoteRef run to preserve the auto-number mark -- + assert len(p.r_lst) == 1 + r = p.r_lst[0] + assert r.style == "EndnoteReference" + assert r[-1].tag == qn("w:endnoteRef") + + def it_provides_access_to_its_inner_content_elements(self): + endnote = cast( + CT_Endnote, + element("w:endnote{w:id=2}/(w:p,w:tbl,w:p)"), + ) + + content = endnote.inner_content_elements + assert len(content) == 3 diff --git a/tests/oxml/test_footnotes.py b/tests/oxml/test_footnotes.py new file mode 100644 index 000000000..bc691b31f --- /dev/null +++ b/tests/oxml/test_footnotes.py @@ -0,0 +1,119 @@ +"""Unit test suite for the docx.oxml.footnotes module.""" + +from __future__ import annotations + +from typing import cast + +from docx.oxml.footnotes import CT_Footnote, CT_Footnotes +from docx.oxml.ns import qn + +from ..unitutil.cxml import element + + +class DescribeCT_Footnotes: + """Unit test suite for `docx.oxml.footnotes.CT_Footnotes` objects.""" + + def it_provides_access_to_its_footnote_children(self): + footnotes = cast( + CT_Footnotes, + element("w:footnotes/(w:footnote{w:id=0},w:footnote{w:id=1})"), + ) + + assert len(footnotes.footnote_lst) == 2 + + def it_can_add_a_footnote(self): + footnotes = cast( + CT_Footnotes, + element( + "w:footnotes/(w:footnote{w:id=0,w:type=separator}" + ",w:footnote{w:id=1,w:type=continuationSeparator})" + ), + ) + + footnote = footnotes.add_footnote() + + assert footnote.id == 2 + # -- the footnote has a paragraph with FootnoteText style -- + assert len(footnote.p_lst) == 1 + p = footnote.p_lst[0] + assert p.style == "FootnoteText" + # -- the paragraph has a run with FootnoteReference style and footnoteRef -- + assert len(p.r_lst) == 1 + r = p.r_lst[0] + assert r.style == "FootnoteReference" + assert r[-1].tag == qn("w:footnoteRef") + + def it_assigns_sequential_ids_to_added_footnotes(self): + footnotes = cast( + CT_Footnotes, + element( + "w:footnotes/(w:footnote{w:id=0,w:type=separator}" + ",w:footnote{w:id=1,w:type=continuationSeparator})" + ), + ) + + fn1 = footnotes.add_footnote() + fn2 = footnotes.add_footnote() + + assert fn1.id == 2 + assert fn2.id == 3 + + def it_skips_used_ids_when_assigning(self): + footnotes = cast( + CT_Footnotes, + element( + "w:footnotes/(w:footnote{w:id=0,w:type=separator}" + ",w:footnote{w:id=1,w:type=continuationSeparator}" + ",w:footnote{w:id=2})" + ), + ) + + footnote = footnotes.add_footnote() + + assert footnote.id == 3 + + +class DescribeCT_Footnote: + """Unit test suite for `docx.oxml.footnotes.CT_Footnote` objects.""" + + def it_provides_access_to_its_id(self): + footnote = cast(CT_Footnote, element("w:footnote{w:id=42}")) + + assert footnote.id == 42 + + def it_provides_access_to_its_type(self): + footnote = cast(CT_Footnote, element("w:footnote{w:id=0,w:type=separator}")) + + assert footnote.type == "separator" + + def it_returns_None_for_type_when_not_present(self): + footnote = cast(CT_Footnote, element("w:footnote{w:id=2}")) + + assert footnote.type is None + + def it_can_clear_its_content(self): + footnote = cast( + CT_Footnote, + element('w:footnote{w:id=2}/(w:p/w:r/w:t"Para one",w:p/w:r/w:t"Para two")'), + ) + assert len(footnote.p_lst) == 2 + + footnote.clear_content() + + assert len(footnote.p_lst) == 1 + p = footnote.p_lst[0] + assert p.style == "FootnoteText" + # -- the paragraph has a footnoteRef run to preserve the auto-number mark -- + assert len(p.r_lst) == 1 + r = p.r_lst[0] + assert r.style == "FootnoteReference" + assert r[-1].tag == qn("w:footnoteRef") + + def it_provides_access_to_its_inner_content_elements(self): + footnote = cast( + CT_Footnote, + element("w:footnote{w:id=2}/(w:p,w:tbl,w:p)"), + ) + + content = footnote.inner_content_elements + assert len(content) == 3 diff --git a/tests/oxml/test_section.py b/tests/oxml/test_section.py index 8cf0bd9b7..6adddbb45 100644 --- a/tests/oxml/test_section.py +++ b/tests/oxml/test_section.py @@ -4,11 +4,87 @@ from typing import cast -from docx.oxml.section import CT_HdrFtr +import pytest + +from docx.oxml.section import CT_Col, CT_Cols, CT_HdrFtr, CT_SectPr from docx.oxml.table import CT_Tbl from docx.oxml.text.paragraph import CT_P +from docx.shared import Inches, Twips + +from ..unitutil.cxml import element, xml + + +class DescribeCT_Col: + """Unit-test suite for `docx.oxml.section.CT_Col`.""" + + @pytest.mark.parametrize( + ("col_cxml", "expected_w", "expected_space"), + [ + ("w:col", None, None), + ("w:col{w:w=4320,w:space=720}", Twips(4320), Twips(720)), + ], + ) + def it_knows_its_width_and_space(self, col_cxml, expected_w, expected_space): + col = cast(CT_Col, element(col_cxml)) + assert col.w == expected_w + assert col.space == expected_space + + +class DescribeCT_Cols: + """Unit-test suite for `docx.oxml.section.CT_Cols`.""" + + @pytest.mark.parametrize( + ("cols_cxml", "expected_num", "expected_space", "expected_eq"), + [ + ("w:cols", None, None, None), + ("w:cols{w:num=2,w:space=720,w:equalWidth=1}", 2, Twips(720), True), + ("w:cols{w:num=3,w:equalWidth=0}", 3, None, False), + ], + ) + def it_knows_its_attributes(self, cols_cxml, expected_num, expected_space, expected_eq): + cols = cast(CT_Cols, element(cols_cxml)) + assert cols.num == expected_num + assert cols.space == expected_space + assert cols.equalWidth == expected_eq + + def it_provides_access_to_its_col_children(self): + cols = cast( + CT_Cols, + element("w:cols/(w:col{w:w=4320,w:space=720},w:col{w:w=4320})"), + ) + col_lst = cols.col_lst + assert len(col_lst) == 2 + assert col_lst[0].w == Twips(4320) + assert col_lst[0].space == Twips(720) + assert col_lst[1].w == Twips(4320) + assert col_lst[1].space is None + + +class DescribeCT_SectPr_cols: + """Unit-test suite for CT_SectPr column-related features.""" + + def it_can_access_its_cols_child(self): + sectPr = cast(CT_SectPr, element("w:sectPr/w:cols{w:num=2}")) + cols = sectPr.cols + assert cols is not None + assert cols.num == 2 + + def it_returns_None_when_no_cols_child(self): + sectPr = cast(CT_SectPr, element("w:sectPr")) + assert sectPr.cols is None + + def it_can_add_a_cols_child(self): + sectPr = cast(CT_SectPr, element("w:sectPr")) + cols = sectPr.get_or_add_cols() + assert cols is not None + assert sectPr.cols is cols -from ..unitutil.cxml import element + def it_inserts_cols_in_the_right_position(self): + sectPr = cast(CT_SectPr, element("w:sectPr/w:pgMar")) + cols = sectPr.get_or_add_cols() + assert cols is not None + expected = xml("w:sectPr/(w:pgMar,w:cols)") + assert sectPr.xml == expected class DescribeCT_HdrFtr: diff --git a/tests/oxml/test_settings.py b/tests/oxml/test_settings.py new file mode 100644 index 000000000..542e606ab --- /dev/null +++ b/tests/oxml/test_settings.py @@ -0,0 +1,175 @@ +# pyright: reportPrivateUsage=false + +"""Unit-test suite for `docx.oxml.settings` module.""" + +from __future__ import annotations + +from typing import cast + +import pytest + +from docx.oxml.settings import CT_Settings +from docx.shared import Twips + +from ..unitutil.cxml import element, xml + + +class DescribeCT_Settings: + """Unit-test suite for `docx.oxml.settings.CT_Settings`.""" + + @pytest.mark.parametrize( + ("cxml", "expected_value"), + [ + ("w:settings", None), + ("w:settings/w:zoom{w:percent=100}", 100), + ("w:settings/w:zoom{w:percent=75}", 75), + ("w:settings/w:zoom", None), + ], + ) + def it_can_get_the_zoom_percent(self, cxml: str, expected_value: int | None): + settings = cast(CT_Settings, element(cxml)) + assert settings.zoom_percent == expected_value + + @pytest.mark.parametrize( + ("cxml", "new_value", "expected_cxml"), + [ + ("w:settings", 100, "w:settings/w:zoom{w:percent=100}"), + ("w:settings/w:zoom{w:percent=75}", 150, "w:settings/w:zoom{w:percent=150}"), + ("w:settings/w:zoom{w:percent=100}", None, "w:settings"), + ], + ) + def it_can_set_the_zoom_percent( + self, cxml: str, new_value: int | None, expected_cxml: str + ): + settings = cast(CT_Settings, element(cxml)) + settings.zoom_percent = new_value + assert settings.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("cxml", "expected_value"), + [ + ("w:settings", False), + ("w:settings/w:trackRevisions", True), + ("w:settings/w:trackRevisions{w:val=0}", False), + ("w:settings/w:trackRevisions{w:val=true}", True), + ], + ) + def it_can_get_trackRevisions(self, cxml: str, expected_value: bool): + settings = cast(CT_Settings, element(cxml)) + assert settings.trackRevisions_val is expected_value + + @pytest.mark.parametrize( + ("cxml", "new_value", "expected_cxml"), + [ + ("w:settings", True, "w:settings/w:trackRevisions"), + ("w:settings/w:trackRevisions", False, "w:settings"), + ("w:settings/w:trackRevisions{w:val=0}", True, "w:settings/w:trackRevisions"), + ], + ) + def it_can_set_trackRevisions( + self, cxml: str, new_value: bool, expected_cxml: str + ): + settings = cast(CT_Settings, element(cxml)) + settings.trackRevisions_val = new_value + assert settings.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("cxml", "expected_value"), + [ + ("w:settings", None), + ("w:settings/w:defaultTabStop{w:val=720}", Twips(720)), + ("w:settings/w:defaultTabStop{w:val=360}", Twips(360)), + ], + ) + def it_can_get_the_defaultTabStop(self, cxml: str, expected_value): + settings = cast(CT_Settings, element(cxml)) + assert settings.defaultTabStop_val == expected_value + + @pytest.mark.parametrize( + ("cxml", "new_value", "expected_cxml"), + [ + ("w:settings", Twips(720), "w:settings/w:defaultTabStop{w:val=720}"), + ( + "w:settings/w:defaultTabStop{w:val=720}", + Twips(360), + "w:settings/w:defaultTabStop{w:val=360}", + ), + ("w:settings/w:defaultTabStop{w:val=720}", None, "w:settings"), + ], + ) + def it_can_set_the_defaultTabStop( + self, cxml: str, new_value, expected_cxml: str + ): + settings = cast(CT_Settings, element(cxml)) + settings.defaultTabStop_val = new_value + assert settings.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("cxml", "expected_edit", "expected_enforcement"), + [ + ("w:settings", None, False), + ( + "w:settings/w:documentProtection{w:edit=readOnly,w:enforcement=1}", + "readOnly", + True, + ), + ( + "w:settings/w:documentProtection{w:edit=comments,w:enforcement=0}", + "comments", + False, + ), + ("w:settings/w:documentProtection{w:edit=forms}", "forms", False), + ("w:settings/w:documentProtection", None, False), + ], + ) + def it_can_get_document_protection( + self, + cxml: str, + expected_edit: str | None, + expected_enforcement: bool, + ): + settings = cast(CT_Settings, element(cxml)) + assert settings.documentProtection_edit == expected_edit + assert settings.documentProtection_enforcement is expected_enforcement + + def it_can_get_the_compatibilityMode_when_absent(self): + settings = cast(CT_Settings, element("w:settings")) + assert settings.compatibilityMode is None + + def it_can_get_the_compatibilityMode_when_present(self): + settings = cast(CT_Settings, element("w:settings/w:compat")) + # -- no compatSetting children yet, so None -- + assert settings.compatibilityMode is None + + def it_can_set_the_compatibilityMode(self): + settings = cast(CT_Settings, element("w:settings")) + settings.compatibilityMode = 15 + assert settings.compatibilityMode == 15 + + def it_can_change_the_compatibilityMode(self): + settings = cast(CT_Settings, element("w:settings")) + settings.compatibilityMode = 14 + assert settings.compatibilityMode == 14 + settings.compatibilityMode = 15 + assert settings.compatibilityMode == 15 + + def it_can_remove_the_compatibilityMode(self): + settings = cast(CT_Settings, element("w:settings")) + settings.compatibilityMode = 15 + assert settings.compatibilityMode == 15 + settings.compatibilityMode = None + assert settings.compatibilityMode is None + assert settings.xml == xml("w:settings") + + @pytest.mark.parametrize( + ("cxml", "expected_value"), + [ + ("w:settings", False), + ("w:settings/w:evenAndOddHeaders", True), + ("w:settings/w:evenAndOddHeaders{w:val=0}", False), + ("w:settings/w:evenAndOddHeaders{w:val=1}", True), + ], + ) + def it_can_get_evenAndOddHeaders(self, cxml: str, expected_value: bool): + settings = cast(CT_Settings, element(cxml)) + assert settings.evenAndOddHeaders_val is expected_value diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index 2c9e05344..a20c5054b 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -8,16 +8,235 @@ import pytest +from docx.enum.table import WD_BORDER_STYLE, WD_SHADING_PATTERN from docx.exceptions import InvalidSpanError from docx.oxml.parser import parse_xml -from docx.oxml.table import CT_Row, CT_Tbl, CT_Tc +from docx.oxml.table import ( + CT_Border, + CT_Row, + CT_Shd, + CT_Tbl, + CT_TblBorders, + CT_TblPr, + CT_Tc, + CT_TcBorders, + CT_TcPr, +) from docx.oxml.text.paragraph import CT_P +from docx.shared import RGBColor from ..unitutil.cxml import element, xml from ..unitutil.file import snippet_seq from ..unitutil.mock import FixtureRequest, Mock, call, instance_mock, method_mock, property_mock +class DescribeCT_Border: + """Unit-test suite for `docx.oxml.table.CT_Border` objects.""" + + @pytest.mark.parametrize( + ("border_cxml", "expected_val"), + [ + ("w:top", None), + ("w:top{w:val=single}", WD_BORDER_STYLE.SINGLE), + ("w:top{w:val=double}", WD_BORDER_STYLE.DOUBLE), + ("w:top{w:val=none}", WD_BORDER_STYLE.NONE), + ], + ) + def it_can_get_the_val_attribute( + self, border_cxml: str, expected_val: WD_BORDER_STYLE | None + ): + border = cast(CT_Border, element(border_cxml)) + assert border.val == expected_val + + @pytest.mark.parametrize( + ("border_cxml", "expected_sz"), + [ + ("w:top", None), + ("w:top{w:sz=4}", 4), + ("w:top{w:sz=12}", 12), + ], + ) + def it_can_get_the_sz_attribute(self, border_cxml: str, expected_sz: int | None): + border = cast(CT_Border, element(border_cxml)) + assert border.sz == expected_sz + + @pytest.mark.parametrize( + ("border_cxml", "expected_color"), + [ + ("w:top", None), + ("w:top{w:color=FF0000}", RGBColor(0xFF, 0x00, 0x00)), + ("w:top{w:color=auto}", "auto"), + ], + ) + def it_can_get_the_color_attribute( + self, border_cxml: str, expected_color: RGBColor | str | None + ): + border = cast(CT_Border, element(border_cxml)) + assert border.color == expected_color + + @pytest.mark.parametrize( + ("border_cxml", "expected_space"), + [ + ("w:top", None), + ("w:top{w:space=0}", 0), + ("w:top{w:space=4}", 4), + ], + ) + def it_can_get_the_space_attribute(self, border_cxml: str, expected_space: int | None): + border = cast(CT_Border, element(border_cxml)) + assert border.space == expected_space + + +class DescribeCT_TblBorders: + """Unit-test suite for `docx.oxml.table.CT_TblBorders` objects.""" + + def it_can_get_and_add_border_children(self): + tblBorders = cast(CT_TblBorders, element("w:tblBorders")) + assert tblBorders.top is None + top = tblBorders.get_or_add_top() + assert isinstance(top, CT_Border) + assert tblBorders.top is top + + def it_inserts_borders_in_the_right_order(self): + tblBorders = cast(CT_TblBorders, element("w:tblBorders")) + tblBorders.get_or_add_insideV() + tblBorders.get_or_add_top() + expected = xml("w:tblBorders/(w:top,w:insideV)") + assert tblBorders.xml == expected + + @pytest.mark.parametrize("attr", ["top", "left", "bottom", "right", "insideH", "insideV"]) + def it_can_remove_each_border(self, attr: str): + tblBorders = cast(CT_TblBorders, element("w:tblBorders")) + get_or_add = getattr(tblBorders, f"get_or_add_{attr}") + remove = getattr(tblBorders, f"_remove_{attr}") + get_or_add() + assert getattr(tblBorders, attr) is not None + remove() + assert getattr(tblBorders, attr) is None + + +class DescribeCT_TcBorders: + """Unit-test suite for `docx.oxml.table.CT_TcBorders` objects.""" + + def it_can_get_and_add_border_children(self): + tcBorders = cast(CT_TcBorders, element("w:tcBorders")) + assert tcBorders.top is None + top = tcBorders.get_or_add_top() + assert isinstance(top, CT_Border) + assert tcBorders.top is top + + def it_inserts_borders_in_the_right_order(self): + tcBorders = cast(CT_TcBorders, element("w:tcBorders")) + tcBorders.get_or_add_right() + tcBorders.get_or_add_top() + expected = xml("w:tcBorders/(w:top,w:right)") + assert tcBorders.xml == expected + + +class DescribeCT_TblPr_borders: + """Unit-test suite for border-related features of CT_TblPr.""" + + def it_can_get_the_tblBorders_child(self): + tblPr = cast(CT_TblPr, element("w:tblPr")) + assert tblPr.tblBorders is None + + def it_can_add_tblBorders(self): + tblPr = cast(CT_TblPr, element("w:tblPr")) + tblBorders = tblPr.get_or_add_tblBorders() + assert isinstance(tblBorders, CT_TblBorders) + assert tblPr.tblBorders is tblBorders + + def it_inserts_tblBorders_in_the_right_position(self): + tblPr = cast(CT_TblPr, element("w:tblPr/(w:tblStyle,w:tblLayout)")) + tblPr.get_or_add_tblBorders() + expected = xml("w:tblPr/(w:tblStyle,w:tblBorders,w:tblLayout)") + assert tblPr.xml == expected + + +class DescribeCT_TcPr_borders: + """Unit-test suite for border-related features of CT_TcPr.""" + + def it_can_get_the_tcBorders_child(self): + tcPr = cast(CT_TcPr, element("w:tcPr")) + assert tcPr.tcBorders is None + + def it_can_add_tcBorders(self): + tcPr = cast(CT_TcPr, element("w:tcPr")) + tcBorders = tcPr.get_or_add_tcBorders() + assert isinstance(tcBorders, CT_TcBorders) + assert tcPr.tcBorders is tcBorders + + def it_inserts_tcBorders_in_the_right_position(self): + tcPr = cast(CT_TcPr, element("w:tcPr/(w:tcW,w:shd)")) + tcPr.get_or_add_tcBorders() + expected = xml("w:tcPr/(w:tcW,w:tcBorders,w:shd)") + assert tcPr.xml == expected + + +class DescribeCT_Shd: + """Unit-test suite for `docx.oxml.table.CT_Shd` objects.""" + + @pytest.mark.parametrize( + ("shd_cxml", "expected_fill"), + [ + ("w:shd", None), + ("w:shd{w:fill=D9E2F3}", RGBColor(0xD9, 0xE2, 0xF3)), + ("w:shd{w:fill=auto}", "auto"), + ], + ) + def it_can_get_the_fill_attribute(self, shd_cxml: str, expected_fill: RGBColor | str | None): + shd = cast(CT_Shd, element(shd_cxml)) + assert shd.fill == expected_fill + + @pytest.mark.parametrize( + ("shd_cxml", "expected_val"), + [ + ("w:shd", None), + ("w:shd{w:val=clear}", WD_SHADING_PATTERN.CLEAR), + ("w:shd{w:val=solid}", WD_SHADING_PATTERN.SOLID), + ("w:shd{w:val=pct10}", WD_SHADING_PATTERN.PCT_10), + ], + ) + def it_can_get_the_val_attribute( + self, shd_cxml: str, expected_val: WD_SHADING_PATTERN | None + ): + shd = cast(CT_Shd, element(shd_cxml)) + assert shd.val == expected_val + + +class DescribeCT_TcPr: + """Unit-test suite for `docx.oxml.table.CT_TcPr` objects.""" + + @pytest.mark.parametrize( + ("tcPr_cxml", "expected_shd_present"), + [ + ("w:tcPr", False), + ("w:tcPr/w:shd{w:val=clear,w:fill=D9E2F3}", True), + ], + ) + def it_can_get_the_shd_child(self, tcPr_cxml: str, expected_shd_present: bool): + tcPr = cast(CT_TcPr, element(tcPr_cxml)) + if expected_shd_present: + assert tcPr.shd is not None + assert isinstance(tcPr.shd, CT_Shd) + else: + assert tcPr.shd is None + + def it_can_add_a_shd_child(self): + tcPr = cast(CT_TcPr, element("w:tcPr")) + shd = tcPr.get_or_add_shd() + assert isinstance(shd, CT_Shd) + assert tcPr.shd is shd + + def it_inserts_shd_in_the_right_position(self): + tcPr = cast(CT_TcPr, element("w:tcPr/(w:tcW,w:vAlign{w:val=center})")) + shd = tcPr.get_or_add_shd() + assert isinstance(shd, CT_Shd) + # shd should appear between tcW and vAlign + expected_xml = xml("w:tcPr/(w:tcW,w:shd,w:vAlign{w:val=center})") + assert tcPr.xml == expected_xml + + class DescribeCT_Row: @pytest.mark.parametrize( ("tr_cxml", "expected_cxml"), @@ -33,6 +252,70 @@ def it_can_add_a_trPr(self, tr_cxml: str, expected_cxml: str): tr._add_trPr() assert tr.xml == xml(expected_cxml) + @pytest.mark.parametrize( + ("tr_cxml", "expected_value"), + [ + ("w:tr", True), + ("w:tr/w:trPr", True), + ("w:tr/w:trPr/w:cantSplit", False), + ("w:tr/w:trPr/w:cantSplit{w:val=true}", False), + ("w:tr/w:trPr/w:cantSplit{w:val=false}", True), + ], + ) + def it_knows_whether_it_allows_break_across_pages( + self, tr_cxml: str, expected_value: bool + ): + tr = cast(CT_Row, element(tr_cxml)) + assert tr.allow_break_across_pages is expected_value + + @pytest.mark.parametrize( + ("tr_cxml", "new_value", "expected_cxml"), + [ + ("w:tr", False, "w:tr/w:trPr/w:cantSplit"), + ("w:tr/w:trPr", False, "w:tr/w:trPr/w:cantSplit"), + ("w:tr/w:trPr/w:cantSplit", True, "w:tr/w:trPr"), + ("w:tr/w:trPr/w:cantSplit", None, "w:tr/w:trPr"), + ("w:tr", True, "w:tr/w:trPr"), + ], + ) + def it_can_change_whether_it_allows_break_across_pages( + self, tr_cxml: str, new_value: bool | None, expected_cxml: str + ): + tr = cast(CT_Row, element(tr_cxml)) + tr.allow_break_across_pages = new_value + assert tr.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("tr_cxml", "expected_value"), + [ + ("w:tr", False), + ("w:tr/w:trPr", False), + ("w:tr/w:trPr/w:tblHeader", True), + ("w:tr/w:trPr/w:tblHeader{w:val=true}", True), + ("w:tr/w:trPr/w:tblHeader{w:val=false}", False), + ], + ) + def it_knows_whether_it_is_a_header_row(self, tr_cxml: str, expected_value: bool): + tr = cast(CT_Row, element(tr_cxml)) + assert tr.is_header is expected_value + + @pytest.mark.parametrize( + ("tr_cxml", "new_value", "expected_cxml"), + [ + ("w:tr", True, "w:tr/w:trPr/w:tblHeader"), + ("w:tr/w:trPr", True, "w:tr/w:trPr/w:tblHeader"), + ("w:tr/w:trPr/w:tblHeader", False, "w:tr/w:trPr"), + ("w:tr/w:trPr/w:tblHeader", None, "w:tr/w:trPr"), + ("w:tr", False, "w:tr/w:trPr"), + ], + ) + def it_can_change_whether_it_is_a_header_row( + self, tr_cxml: str, new_value: bool | None, expected_cxml: str + ): + tr = cast(CT_Row, element(tr_cxml)) + tr.is_header = new_value + assert tr.xml == xml(expected_cxml) + @pytest.mark.parametrize(("snippet_idx", "row_idx", "col_idx"), [(0, 0, 3), (1, 0, 1)]) def it_raises_on_tc_at_grid_col(self, snippet_idx: int, row_idx: int, col_idx: int): tr = cast(CT_Tbl, parse_xml(snippet_seq("tbl-cells")[snippet_idx])).tr_lst[row_idx] diff --git a/tests/oxml/test_tracked_changes.py b/tests/oxml/test_tracked_changes.py new file mode 100644 index 000000000..4438b28ea --- /dev/null +++ b/tests/oxml/test_tracked_changes.py @@ -0,0 +1,102 @@ +# pyright: reportPrivateUsage=false + +"""Unit-test suite for `docx.oxml.tracked_changes` module.""" + +from __future__ import annotations + +import datetime as dt +from typing import cast + +import pytest + +from docx.oxml.tracked_changes import CT_Del, CT_DelText, CT_Ins + +from ..unitutil.cxml import element + + +class DescribeCT_Ins: + """Unit-test suite for `docx.oxml.tracked_changes.CT_Ins`.""" + + def it_knows_its_id(self): + ins = cast(CT_Ins, element("w:ins{w:id=1,w:author=Alice}")) + assert ins.id == 1 + + def it_knows_its_author(self): + ins = cast(CT_Ins, element("w:ins{w:id=1,w:author=Alice}")) + assert ins.author == "Alice" + + def it_knows_its_date(self): + ins = cast(CT_Ins, element("w:ins{w:id=1,w:author=Alice,w:date=2023-10-01T12:00:00Z}")) + assert ins.date == dt.datetime(2023, 10, 1, 12, 0, 0, tzinfo=dt.timezone.utc) + + def it_returns_None_when_date_is_absent(self): + ins = cast(CT_Ins, element("w:ins{w:id=1,w:author=Alice}")) + assert ins.date is None + + @pytest.mark.parametrize( + ("cxml", "expected_text"), + [ + ("w:ins{w:id=1,w:author=A}", ""), + ('w:ins{w:id=1,w:author=A}/w:r/w:t"hello"', "hello"), + ( + 'w:ins{w:id=1,w:author=A}/(w:r/w:t"hello ",w:r/w:t"world")', + "hello world", + ), + ], + ) + def it_can_produce_its_text(self, cxml: str, expected_text: str): + ins = cast(CT_Ins, element(cxml)) + assert ins.text == expected_text + + def it_provides_access_to_its_runs(self): + ins = cast(CT_Ins, element('w:ins{w:id=1,w:author=A}/(w:r/w:t"a",w:r/w:t"b")')) + assert len(ins.r_lst) == 2 + + +class DescribeCT_Del: + """Unit-test suite for `docx.oxml.tracked_changes.CT_Del`.""" + + def it_knows_its_id(self): + del_elm = cast(CT_Del, element("w:del{w:id=2,w:author=Bob}")) + assert del_elm.id == 2 + + def it_knows_its_author(self): + del_elm = cast(CT_Del, element("w:del{w:id=2,w:author=Bob}")) + assert del_elm.author == "Bob" + + def it_knows_its_date(self): + del_elm = cast( + CT_Del, element("w:del{w:id=2,w:author=Bob,w:date=2023-11-15T09:30:00Z}") + ) + assert del_elm.date == dt.datetime(2023, 11, 15, 9, 30, 0, tzinfo=dt.timezone.utc) + + def it_returns_None_when_date_is_absent(self): + del_elm = cast(CT_Del, element("w:del{w:id=2,w:author=Bob}")) + assert del_elm.date is None + + @pytest.mark.parametrize( + ("cxml", "expected_text"), + [ + ("w:del{w:id=2,w:author=B}", ""), + ('w:del{w:id=2,w:author=B}/w:r/w:delText"removed"', "removed"), + ( + 'w:del{w:id=2,w:author=B}/(w:r/w:delText"foo ",w:r/w:delText"bar")', + "foo bar", + ), + ], + ) + def it_can_produce_its_text(self, cxml: str, expected_text: str): + del_elm = cast(CT_Del, element(cxml)) + assert del_elm.text == expected_text + + +class DescribeCT_DelText: + """Unit-test suite for `docx.oxml.tracked_changes.CT_DelText`.""" + + def it_can_report_its_text(self): + dt_elm = cast(CT_DelText, element('w:delText"some deleted text"')) + assert str(dt_elm) == "some deleted text" + + def it_returns_empty_string_when_no_content(self): + dt_elm = cast(CT_DelText, element("w:delText")) + assert str(dt_elm) == "" diff --git a/tests/oxml/text/test_hyperlink.py b/tests/oxml/text/test_hyperlink.py index f5cec4761..ec2a289ca 100644 --- a/tests/oxml/text/test_hyperlink.py +++ b/tests/oxml/text/test_hyperlink.py @@ -43,3 +43,11 @@ def it_has_zero_or_more_runs_containing_the_hyperlink_text(self): assert [type(r) for r in rs] == [CT_R, CT_R] assert rs[0].text == "blog" assert rs[1].text == " post" + + def it_can_add_a_run(self): + hyperlink = cast(CT_Hyperlink, element("w:hyperlink")) + + r = hyperlink.add_r() + + assert isinstance(r, CT_R) + assert len(hyperlink.r_lst) == 1 diff --git a/tests/oxml/text/test_paragraph.py b/tests/oxml/text/test_paragraph.py new file mode 100644 index 000000000..b045be0f5 --- /dev/null +++ b/tests/oxml/text/test_paragraph.py @@ -0,0 +1,59 @@ +"""Test suite for the docx.oxml.text.paragraph module.""" + +from typing import cast + +from docx.oxml.ns import qn +from docx.oxml.parser import OxmlElement +from docx.oxml.text.paragraph import CT_P + +from ...unitutil.cxml import element + + +class DescribeCT_P: + """Unit-test suite for the CT_P () element.""" + + def it_can_add_an_external_hyperlink(self): + p = cast(CT_P, element("w:p")) + + hyperlink = p.add_hyperlink(rId="rId7", anchor=None, text="Click", rPr=None) + + assert hyperlink.rId == "rId7" + assert hyperlink.anchor is None + assert hyperlink.history is True + rs = hyperlink.r_lst + assert len(rs) == 1 + assert rs[0].text == "Click" + assert rs[0].rPr is None + + def it_can_add_an_internal_hyperlink(self): + p = cast(CT_P, element("w:p")) + + hyperlink = p.add_hyperlink(rId=None, anchor="bookmark1", text="Go", rPr=None) + + assert hyperlink.rId is None + assert hyperlink.anchor == "bookmark1" + assert hyperlink.history is True + assert hyperlink.r_lst[0].text == "Go" + + def it_can_add_a_hyperlink_with_rPr(self): + p = cast(CT_P, element("w:p")) + rPr = OxmlElement("w:rPr") + rStyle = OxmlElement("w:rStyle") + rStyle.set(qn("w:val"), "Hyperlink") + rPr.append(rStyle) + + hyperlink = p.add_hyperlink(rId="rId1", anchor=None, text="Link", rPr=rPr) + + r = hyperlink.r_lst[0] + assert r.rPr is not None + rStyle_elem = r.rPr.find(qn("w:rStyle")) + assert rStyle_elem is not None + assert rStyle_elem.get(qn("w:val")) == "Hyperlink" + + def it_appends_the_hyperlink_as_the_last_child(self): + p = cast(CT_P, element('w:p/w:r/w:t"existing"')) + + p.add_hyperlink(rId="rId1", anchor=None, text="Link", rPr=None) + + children = list(p) + assert children[-1].tag == qn("w:hyperlink") diff --git a/tests/oxml/text/test_run.py b/tests/oxml/text/test_run.py index 6aad7cd02..531947ef9 100644 --- a/tests/oxml/text/test_run.py +++ b/tests/oxml/text/test_run.py @@ -4,6 +4,7 @@ import pytest +from docx.oxml.text.paragraph import CT_P from docx.oxml.text.run import CT_R from ...unitutil.cxml import element, xml @@ -39,3 +40,67 @@ def it_can_assemble_the_text_in_the_run(self): r = cast(CT_R, element(cxml)) assert r.text == "\n\n-\tfoobar\t" + + @pytest.mark.parametrize( + ("p_cxml", "offset", "expected_left_text", "expected_right_text"), + [ + # split in middle of text + ('w:p/w:r/w:t"foobar"', 3, "foo", "bar"), + # split at beginning — left run is empty + ('w:p/w:r/w:t"foobar"', 0, "", "foobar"), + # split at end — right run is empty + ('w:p/w:r/w:t"foobar"', 6, "foobar", ""), + # split run with formatting — both get rPr + ('w:p/w:r/(w:rPr/w:b,w:t"foobar")', 3, "foo", "bar"), + ], + ) + def it_can_split_at_a_character_offset( + self, + p_cxml: str, + offset: int, + expected_left_text: str, + expected_right_text: str, + ): + p = cast(CT_P, element(p_cxml)) + r = p.r_lst[0] + + new_r = r.split_run(offset) + + assert r.text == expected_left_text + assert new_r.text == expected_right_text + # -- new run is next sibling -- + assert r.getnext() is new_r + assert len(p.r_lst) == 2 + + def it_copies_rPr_to_the_new_run_on_split(self): + p = cast(CT_P, element('w:p/w:r/(w:rPr/(w:b,w:i),w:t"foobar")')) + r = p.r_lst[0] + + new_r = r.split_run(3) + + # -- both runs have bold+italic -- + assert r.rPr is not None + assert new_r.rPr is not None + assert r.rPr.xml == new_r.rPr.xml + # -- but they are distinct elements, not the same object -- + assert r.rPr is not new_r.rPr + + def it_splits_a_run_with_no_formatting(self): + p = cast(CT_P, element('w:p/w:r/w:t"hello"')) + r = p.r_lst[0] + + new_r = r.split_run(2) + + assert r.text == "he" + assert new_r.text == "llo" + assert r.rPr is None + assert new_r.rPr is None + + def it_raises_on_invalid_offset(self): + p = cast(CT_P, element('w:p/w:r/w:t"hello"')) + r = p.r_lst[0] + + with pytest.raises(ValueError, match="offset -1 out of range"): + r.split_run(-1) + with pytest.raises(ValueError, match="offset 6 out of range"): + r.split_run(6) diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index c27990baf..bf90b56a8 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -13,6 +13,7 @@ from docx.package import Package from docx.parts.comments import CommentsPart from docx.parts.document import DocumentPart +from docx.parts.footnotes import FootnotesPart from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.numbering import NumberingPart from docx.parts.settings import SettingsPart @@ -227,6 +228,39 @@ def it_can_get_the_id_of_a_style( styles_.get_style_id.assert_called_once_with(style_, WD_STYLE_TYPE.CHARACTER) assert style_id == "BodyCharacter" + def it_provides_access_to_its_footnotes_part_to_help( + self, package_: Mock, part_related_by_: Mock, footnotes_part_: Mock + ): + part_related_by_.return_value = footnotes_part_ + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) + + footnotes_part = document_part._footnotes_part + + part_related_by_.assert_called_once_with(document_part, RT.FOOTNOTES) + assert footnotes_part is footnotes_part_ + + def and_it_creates_a_default_footnotes_part_if_not_present( + self, + package_: Mock, + part_related_by_: Mock, + FootnotesPart_: Mock, + footnotes_part_: Mock, + relate_to_: Mock, + ): + part_related_by_.side_effect = KeyError + FootnotesPart_.default.return_value = footnotes_part_ + document_part = DocumentPart( + PackURI("/word/document.xml"), CT.WML_DOCUMENT, element("w:document"), package_ + ) + + footnotes_part = document_part._footnotes_part + + FootnotesPart_.default.assert_called_once_with(package_) + relate_to_.assert_called_once_with(document_part, footnotes_part_, RT.FOOTNOTES) + assert footnotes_part is footnotes_part_ + def it_provides_access_to_its_comments_part_to_help( self, package_: Mock, part_related_by_: Mock, comments_part_: Mock ): @@ -352,6 +386,14 @@ def core_properties_(self, request: FixtureRequest): def drop_rel_(self, request: FixtureRequest): return method_mock(request, DocumentPart, "drop_rel", autospec=True) + @pytest.fixture + def FootnotesPart_(self, request: FixtureRequest): + return class_mock(request, "docx.parts.document.FootnotesPart") + + @pytest.fixture + def footnotes_part_(self, request: FixtureRequest): + return instance_mock(request, FootnotesPart) + @pytest.fixture def FooterPart_(self, request: FixtureRequest): return class_mock(request, "docx.parts.document.FooterPart") diff --git a/tests/parts/test_endnotes.py b/tests/parts/test_endnotes.py new file mode 100644 index 000000000..94b85894c --- /dev/null +++ b/tests/parts/test_endnotes.py @@ -0,0 +1,76 @@ +"""Unit test suite for the docx.parts.endnotes module.""" + +from __future__ import annotations + +from typing import cast + +import pytest + +from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.constants import RELATIONSHIP_TYPE as RT +from docx.opc.packuri import PackURI +from docx.opc.part import PartFactory +from docx.oxml.endnotes import CT_Endnotes +from docx.package import Package +from docx.parts.endnotes import EndnotesPart + +from ..unitutil.cxml import element +from ..unitutil.mock import FixtureRequest, Mock, instance_mock, method_mock + + +class DescribeEndnotesPart: + """Unit test suite for `docx.parts.endnotes.EndnotesPart` objects.""" + + def it_is_used_by_the_part_loader_to_construct_an_endnotes_part( + self, package_: Mock, EndnotesPart_load_: Mock, endnotes_part_: Mock + ): + partname = PackURI("/word/endnotes.xml") + content_type = CT.WML_ENDNOTES + reltype = RT.ENDNOTES + blob = b"" + EndnotesPart_load_.return_value = endnotes_part_ + + part = PartFactory(partname, content_type, reltype, blob, package_) + + EndnotesPart_load_.assert_called_once_with(partname, content_type, blob, package_) + assert part is endnotes_part_ + + def it_provides_access_to_its_endnotes_element(self, package_: Mock): + endnotes_elm = cast(CT_Endnotes, element("w:endnotes")) + endnotes_part = EndnotesPart( + PackURI("/word/endnotes.xml"), CT.WML_ENDNOTES, endnotes_elm, package_ + ) + + assert endnotes_part.endnotes_element is endnotes_elm + + def it_constructs_a_default_endnotes_part_to_help(self): + package = Package() + + endnotes_part = EndnotesPart.default(package) + + assert isinstance(endnotes_part, EndnotesPart) + assert endnotes_part.partname == "/word/endnotes.xml" + assert endnotes_part.content_type == CT.WML_ENDNOTES + assert endnotes_part.package is package + assert endnotes_part.element.tag == ( + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}endnotes" + ) + # default template has separator (id=0) and continuation separator (id=1) + endnote_elms = endnotes_part.element.xpath("./w:endnote") + assert len(endnote_elms) == 2 + assert endnote_elms[0].id == 0 + assert endnote_elms[1].id == 1 + + # -- fixtures -------------------------------------------------------------------------------- + + @pytest.fixture + def endnotes_part_(self, request: FixtureRequest) -> Mock: + return instance_mock(request, EndnotesPart) + + @pytest.fixture + def EndnotesPart_load_(self, request: FixtureRequest) -> Mock: + return method_mock(request, EndnotesPart, "load", autospec=False) + + @pytest.fixture + def package_(self, request: FixtureRequest) -> Mock: + return instance_mock(request, Package) diff --git a/tests/parts/test_footnotes.py b/tests/parts/test_footnotes.py new file mode 100644 index 000000000..e7d7760a9 --- /dev/null +++ b/tests/parts/test_footnotes.py @@ -0,0 +1,76 @@ +"""Unit test suite for the docx.parts.footnotes module.""" + +from __future__ import annotations + +from typing import cast + +import pytest + +from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.constants import RELATIONSHIP_TYPE as RT +from docx.opc.packuri import PackURI +from docx.opc.part import PartFactory +from docx.oxml.footnotes import CT_Footnotes +from docx.package import Package +from docx.parts.footnotes import FootnotesPart + +from ..unitutil.cxml import element +from ..unitutil.mock import FixtureRequest, Mock, class_mock, instance_mock, method_mock + + +class DescribeFootnotesPart: + """Unit test suite for `docx.parts.footnotes.FootnotesPart` objects.""" + + def it_is_used_by_the_part_loader_to_construct_a_footnotes_part( + self, package_: Mock, FootnotesPart_load_: Mock, footnotes_part_: Mock + ): + partname = PackURI("/word/footnotes.xml") + content_type = CT.WML_FOOTNOTES + reltype = RT.FOOTNOTES + blob = b"" + FootnotesPart_load_.return_value = footnotes_part_ + + part = PartFactory(partname, content_type, reltype, blob, package_) + + FootnotesPart_load_.assert_called_once_with(partname, content_type, blob, package_) + assert part is footnotes_part_ + + def it_provides_access_to_its_footnotes_element(self, package_: Mock): + footnotes_elm = cast(CT_Footnotes, element("w:footnotes")) + footnotes_part = FootnotesPart( + PackURI("/word/footnotes.xml"), CT.WML_FOOTNOTES, footnotes_elm, package_ + ) + + assert footnotes_part.footnotes_element is footnotes_elm + + def it_constructs_a_default_footnotes_part_to_help(self): + package = Package() + + footnotes_part = FootnotesPart.default(package) + + assert isinstance(footnotes_part, FootnotesPart) + assert footnotes_part.partname == "/word/footnotes.xml" + assert footnotes_part.content_type == CT.WML_FOOTNOTES + assert footnotes_part.package is package + assert footnotes_part.element.tag == ( + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}footnotes" + ) + # default template has separator (id=0) and continuation separator (id=1) + footnote_elms = footnotes_part.element.xpath("./w:footnote") + assert len(footnote_elms) == 2 + assert footnote_elms[0].id == 0 + assert footnote_elms[1].id == 1 + + # -- fixtures -------------------------------------------------------------------------------- + + @pytest.fixture + def footnotes_part_(self, request: FixtureRequest) -> Mock: + return instance_mock(request, FootnotesPart) + + @pytest.fixture + def FootnotesPart_load_(self, request: FixtureRequest) -> Mock: + return method_mock(request, FootnotesPart, "load", autospec=False) + + @pytest.fixture + def package_(self, request: FixtureRequest) -> Mock: + return instance_mock(request, Package) diff --git a/tests/parts/test_story.py b/tests/parts/test_story.py index 9a1dc7fab..363511636 100644 --- a/tests/parts/test_story.py +++ b/tests/parts/test_story.py @@ -1,10 +1,14 @@ """Unit test suite for the docx.parts.story module.""" +import io + import pytest from docx.enum.style import WD_STYLE_TYPE +from docx.image.constants import MIME_TYPE from docx.image.image import Image from docx.opc.constants import RELATIONSHIP_TYPE as RT +from docx.oxml.shape import CT_Inline from docx.package import Package from docx.parts.document import DocumentPart from docx.parts.image import ImagePart @@ -13,7 +17,7 @@ from ..unitutil.cxml import element from ..unitutil.file import snippet_text -from ..unitutil.mock import instance_mock, method_mock, property_mock +from ..unitutil.mock import class_mock, instance_mock, method_mock, property_mock class DescribeStoryPart: @@ -69,6 +73,28 @@ def it_can_create_a_new_pic_inline(self, get_or_add_image_, image_, next_id_prop image_.scaled_dimensions.assert_called_once_with(100, 200) assert inline.xml == expected_xml + def it_can_create_a_new_svg_pic_inline( + self, get_or_add_image_, image_, next_id_prop_, _generate_svg_fallback_ + ): + # First call returns the SVG image rId, second returns the fallback PNG rId + get_or_add_image_.side_effect = [("rId7", image_), ("rId8", image_)] + image_.scaled_dimensions.return_value = 400, 300 + image_.filename = "drawing.svg" + image_.content_type = MIME_TYPE.SVG + next_id_prop_.return_value = 5 + _generate_svg_fallback_.return_value = b"fake-png-bytes" + story_part = StoryPart(None, None, None, None) + + inline = story_part.new_pic_inline("drawing.svg", width=400, height=300) + + assert get_or_add_image_.call_count == 2 + # Second call should be for the fallback PNG stream + fallback_call_args = get_or_add_image_.call_args_list[1] + fallback_stream = fallback_call_args[0][1] + assert isinstance(fallback_stream, io.BytesIO) + assert fallback_stream.getvalue() == b"fake-png-bytes" + assert isinstance(inline, CT_Inline) + def it_knows_the_next_available_xml_id(self, next_id_fixture): story_element, expected_value = next_id_fixture story_part = StoryPart(None, None, story_element, None) @@ -115,6 +141,10 @@ def document_part_(self, request): def _document_part_prop_(self, request): return property_mock(request, StoryPart, "_document_part") + @pytest.fixture + def _generate_svg_fallback_(self, request): + return method_mock(request, StoryPart, "_generate_svg_fallback") + @pytest.fixture def get_or_add_image_(self, request): return method_mock(request, StoryPart, "get_or_add_image") diff --git a/tests/ref-docs/README.md b/tests/ref-docs/README.md new file mode 100644 index 000000000..9d4e92d43 --- /dev/null +++ b/tests/ref-docs/README.md @@ -0,0 +1,52 @@ +# Reference Documents + +This directory contains reference `.docx` files created in Microsoft Word for use in +testing. These files serve as ground truth for validating that python-docx can correctly +read documents produced by Word. + +## How to Use + +Reference files are used in Layer 4 (Reference File Comparison) tests. Test code reads +these files with python-docx and asserts the parsed content matches expectations. + +```python +from docx import Document +from tests.helpers.refcmp import ref_docx_path + +def it_reads_a_word_comments_file(): + doc = Document(ref_docx_path("comments-simple")) + comments = doc.comments + assert len(comments) == 1 + assert comments.get(0).author == "John Doe" +``` + +## Reference Files + +### comments-simple.docx (planned) +- One comment on a single word +- Author: "John Doe", Initials: "JD" +- Comment text: "This is a simple comment." + +### comments-threaded.docx (planned) +- Parent comment with 2 reply comments +- Multiple authors +- Demonstrates reply threading via `w16cid:paraIdParent` + +### comments-multi-author.docx (planned) +- Comments by 3 different authors +- Each with distinct initials + +### comments-formatted.docx (planned) +- Comment containing bold and italic text +- Comment containing multiple paragraphs + +## Creating Reference Files + +1. Open Microsoft Word (any recent version) +2. Create the document content described above +3. Save as `.docx` format +4. Place the file in this directory +5. Update this README with the actual content description + +These files are committed to the repository and should only be recreated when +the expected content changes. diff --git a/tests/test_api.py b/tests/test_api.py index 6b5d3ae07..bf049a601 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -35,6 +35,16 @@ def it_opens_the_default_docx_if_none_specified( Package_.open.assert_called_once_with("default-document.docx") assert document is document_ + def it_opens_a_docm_file(self, Package_: Mock, document_: Mock): + document_part = Package_.open.return_value.main_document_part + document_part.document = document_ + document_part.content_type = CT.WML_DOCUMENT_MACRO + + document = DocumentFactoryFn("foobar.docm") + + Package_.open.assert_called_once_with("foobar.docm") + assert document is document_ + def it_raises_on_not_a_Word_file(self, Package_: Mock): Package_.open.return_value.main_document_part.content_type = "BOGUS" diff --git a/tests/test_bookmark_integration.py b/tests/test_bookmark_integration.py new file mode 100644 index 000000000..6fc16c302 --- /dev/null +++ b/tests/test_bookmark_integration.py @@ -0,0 +1,117 @@ +# pyright: reportPrivateUsage=false + +"""Integration tests for bookmark feature across paragraph and document.""" + +from __future__ import annotations + +from typing import cast + +from docx.bookmarks import Bookmark, Bookmarks +from docx.oxml.document import CT_Body, CT_Document +from docx.oxml.ns import qn +from docx.text.paragraph import Paragraph + +from .unitutil.cxml import element + + +class DescribeParagraph_add_bookmark: + """Unit-test suite for `Paragraph.add_bookmark()`.""" + + def it_can_add_a_bookmark_wrapping_whole_paragraph(self): + body = cast(CT_Body, element('w:body/w:p/w:r/w:t"hello"')) + p_elm = body.p_lst[0] + para = Paragraph(p_elm, None) # type: ignore[arg-type] + + bm = para.add_bookmark("test_bm") + + assert isinstance(bm, Bookmark) + assert bm.name == "test_bm" + assert bm.bookmark_id == 0 + # -- bookmarkStart is first child (no pPr), bookmarkEnd is last -- + children = list(p_elm) + assert children[0].tag == qn("w:bookmarkStart") + assert children[-1].tag == qn("w:bookmarkEnd") + + def it_can_add_a_bookmark_wrapping_whole_paragraph_with_pPr(self): + body = cast(CT_Body, element('w:body/w:p/(w:pPr,w:r/w:t"hello")')) + p_elm = body.p_lst[0] + para = Paragraph(p_elm, None) # type: ignore[arg-type] + + bm = para.add_bookmark("test_bm") + + assert bm.name == "test_bm" + children = list(p_elm) + # -- pPr is first, then bookmarkStart, then run, then bookmarkEnd -- + assert children[0].tag == qn("w:pPr") + assert children[1].tag == qn("w:bookmarkStart") + assert children[-1].tag == qn("w:bookmarkEnd") + + def it_can_add_a_bookmark_around_specific_runs(self): + body = cast( + CT_Body, + element('w:body/w:p/(w:r/w:t"aaa",w:r/w:t"bbb",w:r/w:t"ccc")'), + ) + p_elm = body.p_lst[0] + para = Paragraph(p_elm, None) # type: ignore[arg-type] + runs = para.runs + + bm = para.add_bookmark("mid", start_run=runs[1], end_run=runs[1]) + + assert bm.name == "mid" + # -- bookmarkStart is before the second run, bookmarkEnd is after it -- + children = list(p_elm) + tags = [c.tag for c in children] + bs_idx = tags.index(qn("w:bookmarkStart")) + be_idx = tags.index(qn("w:bookmarkEnd")) + # bookmarkStart should be right before second w:r + assert tags[bs_idx + 1] == qn("w:r") + # bookmarkEnd should be right after that same w:r + assert be_idx == bs_idx + 2 + + def it_allocates_unique_ids(self): + body = cast(CT_Body, element('w:body/w:p/w:r/w:t"hello"')) + p_elm = body.p_lst[0] + para = Paragraph(p_elm, None) # type: ignore[arg-type] + + bm1 = para.add_bookmark("bm1") + bm2 = para.add_bookmark("bm2") + + assert bm1.bookmark_id == 0 + assert bm2.bookmark_id == 1 + + def it_can_add_a_bookmark_with_only_start_run(self): + body = cast( + CT_Body, + element('w:body/w:p/(w:r/w:t"aaa",w:r/w:t"bbb")'), + ) + p_elm = body.p_lst[0] + para = Paragraph(p_elm, None) # type: ignore[arg-type] + runs = para.runs + + bm = para.add_bookmark("single", start_run=runs[0]) + + assert bm.name == "single" + assert bm.bookmark_id == 0 + + +class DescribeDocument_bookmarks: + """Unit-test suite for `Document.bookmarks`.""" + + def it_provides_access_to_document_bookmarks(self): + from docx.document import Document + + doc_elm = cast( + CT_Document, + element( + "w:document/w:body/w:p/" + "(w:bookmarkStart{w:id=0,w:name=bm1},w:bookmarkEnd{w:id=0})" + ), + ) + doc = Document(doc_elm, None) # type: ignore[arg-type] + + bookmarks = doc.bookmarks + + assert isinstance(bookmarks, Bookmarks) + assert len(bookmarks) == 1 + bm = next(iter(bookmarks)) + assert bm.name == "bm1" diff --git a/tests/test_bookmarks.py b/tests/test_bookmarks.py new file mode 100644 index 000000000..5fa8eceaa --- /dev/null +++ b/tests/test_bookmarks.py @@ -0,0 +1,140 @@ +# pyright: reportPrivateUsage=false + +"""Unit test suite for the `docx.bookmarks` module.""" + +from __future__ import annotations + +from typing import cast + +from docx.bookmarks import Bookmark, Bookmarks +from docx.oxml.bookmarks import CT_BookmarkStart +from docx.oxml.document import CT_Body + +from .unitutil.cxml import element + + +class DescribeBookmarks: + """Unit-test suite for `docx.bookmarks.Bookmarks` objects.""" + + def it_knows_how_many_bookmarks_it_contains(self): + body = cast(CT_Body, element("w:body")) + assert len(Bookmarks(body)) == 0 + + body = cast( + CT_Body, + element( + "w:body/w:p/(w:bookmarkStart{w:id=0,w:name=bm1},w:bookmarkEnd{w:id=0})" + ), + ) + assert len(Bookmarks(body)) == 1 + + body = cast( + CT_Body, + element( + "w:body/(w:p/(w:bookmarkStart{w:id=0,w:name=bm1},w:bookmarkEnd{w:id=0})" + ",w:p/(w:bookmarkStart{w:id=1,w:name=bm2},w:bookmarkEnd{w:id=1}))" + ), + ) + assert len(Bookmarks(body)) == 2 + + def it_is_iterable_over_bookmarks(self): + body = cast( + CT_Body, + element( + "w:body/(w:p/(w:bookmarkStart{w:id=0,w:name=bm1},w:bookmarkEnd{w:id=0})" + ",w:p/(w:bookmarkStart{w:id=1,w:name=bm2},w:bookmarkEnd{w:id=1}))" + ), + ) + bookmarks = Bookmarks(body) + + bm_iter = iter(bookmarks) + bm1 = next(bm_iter) + assert isinstance(bm1, Bookmark) + assert bm1.name == "bm1" + bm2 = next(bm_iter) + assert isinstance(bm2, Bookmark) + assert bm2.name == "bm2" + + def it_supports_containment_check_by_name(self): + body = cast( + CT_Body, + element( + "w:body/w:p/(w:bookmarkStart{w:id=0,w:name=bm1},w:bookmarkEnd{w:id=0})" + ), + ) + bookmarks = Bookmarks(body) + assert "bm1" in bookmarks + assert "nonexistent" not in bookmarks + + def it_can_get_a_bookmark_by_name(self): + body = cast( + CT_Body, + element( + "w:body/w:p/(w:bookmarkStart{w:id=0,w:name=bm1},w:bookmarkEnd{w:id=0})" + ), + ) + bookmarks = Bookmarks(body) + + bm = bookmarks.get("bm1") + assert bm is not None + assert bm.name == "bm1" + + assert bookmarks.get("nonexistent") is None + + +class DescribeBookmark: + """Unit-test suite for `docx.bookmarks.Bookmark`.""" + + def it_knows_its_name(self): + body = cast(CT_Body, element("w:body")) + bookmarkStart = cast( + CT_BookmarkStart, + element("w:bookmarkStart{w:id=5,w:name=test_bookmark}"), + ) + bm = Bookmark(bookmarkStart, body) + assert bm.name == "test_bookmark" + + def it_knows_its_bookmark_id(self): + body = cast(CT_Body, element("w:body")) + bookmarkStart = cast( + CT_BookmarkStart, + element("w:bookmarkStart{w:id=42,w:name=bm1}"), + ) + bm = Bookmark(bookmarkStart, body) + assert bm.bookmark_id == 42 + + def it_can_delete_itself(self): + body = cast( + CT_Body, + element( + "w:body/w:p/(w:bookmarkStart{w:id=0,w:name=bm1}" + ",w:r/w:t\"hello\"" + ",w:bookmarkEnd{w:id=0})" + ), + ) + bookmarks = Bookmarks(body) + assert len(bookmarks) == 1 + + bm = next(iter(bookmarks)) + bm.delete() + + assert len(bookmarks) == 0 + # -- bookmarkEnd is also removed -- + assert len(body.xpath(".//w:bookmarkEnd")) == 0 + + def it_can_delete_a_cross_paragraph_bookmark(self): + body = cast( + CT_Body, + element( + "w:body/(w:p/(w:bookmarkStart{w:id=0,w:name=bm1},w:r/w:t\"hello\")" + ",w:p/(w:r/w:t\"world\",w:bookmarkEnd{w:id=0}))" + ), + ) + bookmarks = Bookmarks(body) + assert len(bookmarks) == 1 + + bm = next(iter(bookmarks)) + bm.delete() + + assert len(bookmarks) == 0 + assert len(body.xpath(".//w:bookmarkEnd")) == 0 diff --git a/tests/test_comments.py b/tests/test_comments.py index 0f292ec8a..f75bddb17 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -268,8 +268,102 @@ def it_can_update_the_comment_initials(self, initials: str | None, comments_part assert comment.initials == initials + def it_can_add_a_reply_to_a_comment(self, package_: Mock): + comments_elm = cast(CT_Comments, element("w:comments")) + comments_part = CommentsPart( + PackURI("/word/comments.xml"), + CT.WML_COMMENTS, + comments_elm, + package_, + ) + comments = Comments(comments_elm, comments_part) + parent = comments.add_comment(text="Parent comment", author="Author A", initials="AA") + + reply = parent.add_reply(text="Reply text", author="Author B", initials="BB") + + assert isinstance(reply, Comment) + assert reply.text == "Reply text" + assert reply.author == "Author B" + assert reply.initials == "BB" + assert reply.timestamp is not None + assert reply.comment_id != parent.comment_id + # -- verify the reply is linked to the parent via paraIdParent -- + assert reply._comment_elm.paraIdParent == parent._comment_elm.paraId + + def it_can_list_replies_to_a_comment(self, package_: Mock): + comments_elm = cast(CT_Comments, element("w:comments")) + comments_part = CommentsPart( + PackURI("/word/comments.xml"), + CT.WML_COMMENTS, + comments_elm, + package_, + ) + comments = Comments(comments_elm, comments_part) + parent = comments.add_comment(text="Parent", author="A") + parent.add_reply(text="Reply 1", author="B") + parent.add_reply(text="Reply 2", author="C") + # -- add an unrelated comment -- + comments.add_comment(text="Other comment", author="D") + + replies = parent.replies + + assert len(replies) == 2 + assert replies[0].text == "Reply 1" + assert replies[0].author == "B" + assert replies[1].text == "Reply 2" + assert replies[1].author == "C" + + def and_it_returns_empty_list_when_no_replies(self, package_: Mock): + comments_elm = cast(CT_Comments, element("w:comments")) + comments_part = CommentsPart( + PackURI("/word/comments.xml"), + CT.WML_COMMENTS, + comments_elm, + package_, + ) + comments = Comments(comments_elm, comments_part) + parent = comments.add_comment(text="Parent", author="A") + + assert parent.replies == [] + + def it_can_add_a_reply_with_no_text(self, package_: Mock): + comments_elm = cast(CT_Comments, element("w:comments")) + comments_part = CommentsPart( + PackURI("/word/comments.xml"), + CT.WML_COMMENTS, + comments_elm, + package_, + ) + comments = Comments(comments_elm, comments_part) + parent = comments.add_comment(author="A") + + reply = parent.add_reply(author="B") + + assert isinstance(reply, Comment) + assert [p.text for p in reply.paragraphs] == [""] + + def it_can_add_a_multiline_reply(self, package_: Mock): + comments_elm = cast(CT_Comments, element("w:comments")) + comments_part = CommentsPart( + PackURI("/word/comments.xml"), + CT.WML_COMMENTS, + comments_elm, + package_, + ) + comments = Comments(comments_elm, comments_part) + parent = comments.add_comment(author="A") + + reply = parent.add_reply(text="line 1\nline 2", author="B") + + assert len(reply.paragraphs) == 2 + assert [p.text for p in reply.paragraphs] == ["line 1", "line 2"] + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture def comments_part_(self, request: FixtureRequest): return instance_mock(request, CommentsPart) + + @pytest.fixture + def package_(self, request: FixtureRequest): + return instance_mock(request, Package) diff --git a/tests/test_docm.py b/tests/test_docm.py new file mode 100644 index 000000000..4f08bdb5d --- /dev/null +++ b/tests/test_docm.py @@ -0,0 +1,69 @@ +"""Integration test suite for .docm (macro-enabled document) support.""" + +from __future__ import annotations + +import os +import tempfile +import zipfile + +from docx import Document +from docx.opc.constants import CONTENT_TYPE as CT + + +TESTS_DIR = os.path.dirname(__file__) +DOCM_PATH = os.path.join(TESTS_DIR, "test_files", "macros.docm") + + +class DescribeDocmSupport: + """Integration tests for opening and saving .docm files.""" + + def it_can_open_a_docm_file(self): + document = Document(DOCM_PATH) + assert document is not None + assert document.has_macros is True + + def it_reports_has_macros_False_for_a_regular_docx(self): + document = Document() + assert document.has_macros is False + + def it_preserves_vba_project_on_round_trip(self): + document = Document(DOCM_PATH) + + with tempfile.NamedTemporaryFile(suffix=".docm", delete=False) as tmp: + tmp_path = tmp.name + + try: + document.save(tmp_path) + + # verify the saved file contains vbaProject.bin + with zipfile.ZipFile(tmp_path, "r") as zf: + assert "word/vbaProject.bin" in zf.namelist() + blob = zf.read("word/vbaProject.bin") + assert blob == b"FAKE_VBA_PROJECT_BINARY_DATA_FOR_TESTING" + + # verify the saved file can be re-opened and still has macros + document2 = Document(tmp_path) + assert document2.has_macros is True + finally: + os.unlink(tmp_path) + + def it_preserves_the_macro_content_type_on_round_trip(self): + document = Document(DOCM_PATH) + + with tempfile.NamedTemporaryFile(suffix=".docm", delete=False) as tmp: + tmp_path = tmp.name + + try: + document.save(tmp_path) + + with zipfile.ZipFile(tmp_path, "r") as zf: + content_types_xml = zf.read("[Content_Types].xml").decode("utf-8") + assert CT.WML_DOCUMENT_MACRO in content_types_xml + assert CT.WML_VBA_PROJECT in content_types_xml + finally: + os.unlink(tmp_path) + + def it_can_read_paragraphs_from_a_docm(self): + document = Document(DOCM_PATH) + assert len(document.paragraphs) >= 1 + assert document.paragraphs[0].text == "Document with macros" diff --git a/tests/test_document.py b/tests/test_document.py index 53efacf8d..1d7e7a621 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -12,7 +12,7 @@ from docx.comments import Comment, Comments from docx.document import Document, _Body from docx.enum.section import WD_SECTION -from docx.enum.text import WD_BREAK +from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.coreprops import CoreProperties from docx.oxml.document import CT_Body, CT_Document from docx.parts.document import DocumentPart @@ -79,16 +79,15 @@ def it_raises_on_heading_level_out_of_range(self, document: Document): document.add_heading(level=10) def it_can_add_a_page_break( - self, document: Document, add_paragraph_: Mock, paragraph_: Mock, run_: Mock + self, document: Document, add_paragraph_: Mock, paragraph_: Mock ): add_paragraph_.return_value = paragraph_ - paragraph_.add_run.return_value = run_ + paragraph_.add_page_break.return_value = paragraph_ paragraph = document.add_page_break() add_paragraph_.assert_called_once_with(document) - paragraph_.add_run.assert_called_once_with() - run_.add_break.assert_called_once_with(WD_BREAK.PAGE) + paragraph_.add_page_break.assert_called_once_with() assert paragraph is paragraph_ @pytest.mark.parametrize( @@ -185,6 +184,19 @@ def it_can_save_the_document_to_a_file(self, document_part_: Mock): document_part_.save.assert_called_once_with("foobar.docx") + def it_knows_when_the_document_has_macros(self, document_part_: Mock): + document = Document(cast(CT_Document, element("w:document")), document_part_) + document_part_.part_related_by.return_value = Mock() + + assert document.has_macros is True + document_part_.part_related_by.assert_called_once_with(RT.VBA_PROJECT) + + def it_knows_when_the_document_has_no_macros(self, document_part_: Mock): + document = Document(cast(CT_Document, element("w:document")), document_part_) + document_part_.part_related_by.side_effect = KeyError + + assert document.has_macros is False + def it_provides_access_to_the_comments(self, document_part_: Mock, comments_: Mock): document_part_.comments = comments_ document = Document(cast(CT_Document, element("w:document")), document_part_) diff --git a/tests/test_drawing.py b/tests/test_drawing.py index c8fedb1a4..0ee83f791 100644 --- a/tests/test_drawing.py +++ b/tests/test_drawing.py @@ -9,10 +9,12 @@ import pytest from docx.drawing import Drawing +from docx.enum.shape import WD_DRAWING_TYPE from docx.image.image import Image from docx.oxml.drawing import CT_Drawing from docx.parts.document import DocumentPart from docx.parts.image import ImagePart +from docx.text.paragraph import Paragraph from .unitutil.cxml import element from .unitutil.mock import FixtureRequest, Mock, instance_mock @@ -59,6 +61,86 @@ def but_it_raises_when_the_drawing_does_not_contain_a_Picture(self, document_par with pytest.raises(ValueError, match="drawing does not contain a picture"): drawing.image + def it_provides_access_to_text_in_a_text_box(self, document_part_: Mock): + cxml = ( + "w:drawing/wp:anchor/a:graphic/a:graphicData" + '/wps:wsp/wps:txbx/w:txbxContent/(w:p/w:r/w:t"Hello",w:p/w:r/w:t"World")' + ) + drawing = Drawing(cast(CT_Drawing, element(cxml)), document_part_) + + assert drawing.text == "Hello\nWorld" + + def it_returns_empty_text_when_no_text_frames(self, document_part_: Mock): + drawing = Drawing( + cast(CT_Drawing, element("w:drawing/wp:inline/a:graphic/a:graphicData/pic:pic")), + document_part_, + ) + + assert drawing.text == "" + + def it_provides_access_to_paragraphs_in_a_text_box(self, document_part_: Mock): + cxml = ( + "w:drawing/wp:anchor/a:graphic/a:graphicData" + '/wps:wsp/wps:txbx/w:txbxContent/(w:p/w:r/w:t"Hello",w:p/w:r/w:t"World")' + ) + drawing = Drawing(cast(CT_Drawing, element(cxml)), document_part_) + + paragraphs = drawing.paragraphs + + assert len(paragraphs) == 2 + assert all(isinstance(p, Paragraph) for p in paragraphs) + assert paragraphs[0].text == "Hello" + assert paragraphs[1].text == "World" + + def it_returns_empty_paragraphs_when_no_text_frames(self, document_part_: Mock): + drawing = Drawing( + cast(CT_Drawing, element("w:drawing/wp:inline/a:graphic/a:graphicData/pic:pic")), + document_part_, + ) + + assert drawing.paragraphs == [] + + @pytest.mark.parametrize( + ("cxml", "expected_type"), + [ + ( + "w:drawing/wp:inline/a:graphic/a:graphicData/pic:pic", + WD_DRAWING_TYPE.PICTURE, + ), + ( + "w:drawing/wp:anchor/a:graphic/a:graphicData/pic:pic", + WD_DRAWING_TYPE.PICTURE, + ), + ( + "w:drawing/wp:inline/a:graphic/a:graphicData" + "/wps:wsp/wps:txbx/w:txbxContent/w:p", + WD_DRAWING_TYPE.TEXT_BOX, + ), + ( + "w:drawing/wp:inline/a:graphic/a:graphicData/c:chart", + WD_DRAWING_TYPE.CHART, + ), + ( + "w:drawing/wp:inline/a:graphic/a:graphicData/wps:wsp", + WD_DRAWING_TYPE.SHAPE, + ), + ( + "w:drawing/wp:inline/a:graphic/a:graphicData/wpg:wgp", + WD_DRAWING_TYPE.GROUP, + ), + ( + "w:drawing/wp:inline/a:graphic/a:graphicData/dgm:relIds", + WD_DRAWING_TYPE.DIAGRAM, + ), + ], + ) + def it_knows_its_type( + self, cxml: str, expected_type: WD_DRAWING_TYPE, document_part_: Mock + ): + drawing = Drawing(cast(CT_Drawing, element(cxml)), document_part_) + + assert drawing.type == expected_type + # -- fixtures -------------------------------------------------------------------------------- @pytest.fixture diff --git a/tests/test_endnotes.py b/tests/test_endnotes.py new file mode 100644 index 000000000..f1c16f7f2 --- /dev/null +++ b/tests/test_endnotes.py @@ -0,0 +1,290 @@ +# pyright: reportPrivateUsage=false + +"""Unit test suite for the `docx.endnotes` module.""" + +from __future__ import annotations + +from typing import cast + +import pytest + +from docx.endnotes import Endnote, Endnotes +from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.packuri import PackURI +from docx.oxml.endnotes import CT_Endnote, CT_Endnotes +from docx.oxml.ns import qn +from docx.oxml.text.run import CT_R +from docx.package import Package +from docx.parts.endnotes import EndnotesPart +from docx.text.run import Run + +from .unitutil.cxml import element +from .unitutil.mock import FixtureRequest, Mock, instance_mock + + +class DescribeEndnotes: + """Unit-test suite for `docx.endnotes.Endnotes` objects.""" + + @pytest.mark.parametrize( + ("cxml", "count"), + [ + # -- empty endnotes (only separators) -- + ( + "w:endnotes/(w:endnote{w:id=0,w:type=separator}" + ",w:endnote{w:id=1,w:type=continuationSeparator})", + 0, + ), + # -- one user endnote -- + ( + "w:endnotes/(w:endnote{w:id=0,w:type=separator}" + ",w:endnote{w:id=1,w:type=continuationSeparator}" + ",w:endnote{w:id=2})", + 1, + ), + # -- two user endnotes -- + ( + "w:endnotes/(w:endnote{w:id=0,w:type=separator}" + ",w:endnote{w:id=1,w:type=continuationSeparator}" + ",w:endnote{w:id=2},w:endnote{w:id=3})", + 2, + ), + ], + ) + def it_knows_how_many_endnotes_it_contains(self, cxml: str, count: int, package_: Mock): + endnotes_elm = cast(CT_Endnotes, element(cxml)) + endnotes_part = EndnotesPart( + PackURI("/word/endnotes.xml"), CT.WML_ENDNOTES, endnotes_elm, package_ + ) + endnotes = Endnotes(endnotes_elm, endnotes_part) + + assert len(endnotes) == count + + def it_is_iterable_over_user_endnotes(self, package_: Mock): + endnotes_elm = cast( + CT_Endnotes, + element( + "w:endnotes/(w:endnote{w:id=0,w:type=separator}" + ",w:endnote{w:id=1,w:type=continuationSeparator}" + ",w:endnote{w:id=2},w:endnote{w:id=3})" + ), + ) + endnotes_part = EndnotesPart( + PackURI("/word/endnotes.xml"), CT.WML_ENDNOTES, endnotes_elm, package_ + ) + endnotes = Endnotes(endnotes_elm, endnotes_part) + + endnote_iter = iter(endnotes) + + en1 = next(endnote_iter) + assert type(en1) is Endnote + assert en1.endnote_id == 2 + en2 = next(endnote_iter) + assert type(en2) is Endnote + assert en2.endnote_id == 3 + with pytest.raises(StopIteration): + next(endnote_iter) + + def it_can_add_an_endnote(self, package_: Mock): + endnotes_elm = cast( + CT_Endnotes, + element( + "w:endnotes/(w:endnote{w:id=0,w:type=separator}" + ",w:endnote{w:id=1,w:type=continuationSeparator})" + ), + ) + endnotes_part = EndnotesPart( + PackURI("/word/endnotes.xml"), CT.WML_ENDNOTES, endnotes_elm, package_ + ) + endnotes = Endnotes(endnotes_elm, endnotes_part) + + # -- create a run to anchor the endnote reference -- + para_elm = element("w:p/w:r") + r_elm = cast(CT_R, para_elm[0]) + run = Run(r_elm, endnotes_part) + + endnote = endnotes.add(run) + + # -- an Endnote is returned -- + assert isinstance(endnote, Endnote) + assert endnote.endnote_id == 2 + # -- the endnote part is linked -- + assert endnote.part is endnotes_part + # -- the endnote has a single paragraph with EndnoteText style -- + assert len(endnote.paragraphs) == 1 + assert endnote.paragraphs[0]._p.style == "EndnoteText" + # -- an endnoteReference was inserted into the run -- + ref_elms = r_elm.xpath("./w:endnoteReference") + assert len(ref_elms) == 1 + assert ref_elms[0].get(qn("w:id")) == "2" + # -- the run has EndnoteReference character style -- + assert r_elm.style == "EndnoteReference" + + def it_can_add_an_endnote_with_text(self, package_: Mock): + endnotes_elm = cast( + CT_Endnotes, + element( + "w:endnotes/(w:endnote{w:id=0,w:type=separator}" + ",w:endnote{w:id=1,w:type=continuationSeparator})" + ), + ) + endnotes_part = EndnotesPart( + PackURI("/word/endnotes.xml"), CT.WML_ENDNOTES, endnotes_elm, package_ + ) + endnotes = Endnotes(endnotes_elm, endnotes_part) + + para_elm = element("w:p/w:r") + r_elm = cast(CT_R, para_elm[0]) + run = Run(r_elm, endnotes_part) + + endnote = endnotes.add(run, text="This is an endnote.") + + # -- the first paragraph has the text after the endnote ref run -- + first_para = endnote.paragraphs[0] + assert len(first_para._p.r_lst) == 2 + assert first_para._p.r_lst[1].text == "This is an endnote." + + # -- fixtures -------------------------------------------------------------------------------- + + @pytest.fixture + def package_(self, request: FixtureRequest): + return instance_mock(request, Package) + + +class DescribeEndnote: + """Unit-test suite for `docx.endnotes.Endnote`.""" + + def it_knows_its_endnote_id(self, endnotes_part_: Mock): + endnote_elm = cast(CT_Endnote, element("w:endnote{w:id=42}")) + endnote = Endnote(endnote_elm, endnotes_part_) + + assert endnote.endnote_id == 42 + + def it_provides_access_to_the_paragraphs_it_contains(self, endnotes_part_: Mock): + endnote_elm = cast( + CT_Endnote, + element('w:endnote{w:id=2}/(w:p/w:r/w:t"First para",w:p/w:r/w:t"Second para")'), + ) + endnote = Endnote(endnote_elm, endnotes_part_) + + paragraphs = endnote.paragraphs + + assert len(paragraphs) == 2 + assert [para.text for para in paragraphs] == ["First para", "Second para"] + + @pytest.mark.parametrize( + ("cxml", "expected_value"), + [ + ("w:endnote{w:id=2}", ""), + ('w:endnote{w:id=2}/w:p/w:r/w:t"Endnote text."', "Endnote text."), + ( + 'w:endnote{w:id=2}/(w:p/w:r/w:t"First para",w:p/w:r/w:t"Second para")', + "First para\nSecond para", + ), + ( + 'w:endnote{w:id=2}/(w:p/w:r/w:t"First para",w:p,w:p/w:r/w:t"Second para")', + "First para\n\nSecond para", + ), + ], + ) + def it_can_summarize_its_content_as_text( + self, cxml: str, expected_value: str, endnotes_part_: Mock + ): + assert Endnote(cast(CT_Endnote, element(cxml)), endnotes_part_).text == expected_value + + def it_can_clear_its_content(self, endnotes_part_: Mock): + endnote_elm = cast( + CT_Endnote, + element('w:endnote{w:id=2}/(w:p/w:r/w:t"First",w:p/w:r/w:t"Second")'), + ) + endnote = Endnote(endnote_elm, endnotes_part_) + assert len(endnote.paragraphs) == 2 + + result = endnote.clear() + + assert result is endnote + assert len(endnote.paragraphs) == 1 + p = endnote.paragraphs[0] + assert p.text == "" + assert p._p.style == "EndnoteText" + # -- the paragraph retains the endnoteRef run for the auto-number mark -- + assert len(p._p.r_lst) == 1 + assert p._p.r_lst[0].style == "EndnoteReference" + assert p._p.r_lst[0][-1].tag == qn("w:endnoteRef") + + def it_can_delete_itself(self): + # -- build an endnotes element with a user endnote (id=2) -- + endnotes_elm = cast( + CT_Endnotes, + element( + "w:endnotes/(w:endnote{w:id=0,w:type=separator}" + ",w:endnote{w:id=1,w:type=continuationSeparator}" + ',w:endnote{w:id=2}/w:p/w:r/w:t"Endnote text")' + ), + ) + + # -- build a document element containing the endnoteReference -- + doc_elm = element("w:document/w:body/w:p/w:r/w:endnoteReference{w:id=2}") + document_part_ = Mock() + document_part_.element = doc_elm + endnotes_part_ = Mock() + endnotes_part_.part = endnotes_part_ + endnotes_part_._document_part = document_part_ + + endnote_elm = endnotes_elm.endnote_lst[2] + endnote = Endnote(endnote_elm, endnotes_part_) + + endnote.delete() + + # -- the endnote element is removed from the endnotes part -- + assert len(endnotes_elm.endnote_lst) == 2 + assert all(en.type is not None for en in endnotes_elm.endnote_lst) + # -- the endnoteReference run is removed from the document body -- + refs = doc_elm.xpath(".//w:endnoteReference") + assert len(refs) == 0 + + def it_removes_the_ref_run_when_deleting_if_run_becomes_empty(self): + endnotes_elm = cast( + CT_Endnotes, + element( + "w:endnotes/(w:endnote{w:id=0,w:type=separator}" + ",w:endnote{w:id=1,w:type=continuationSeparator}" + ",w:endnote{w:id=2}/w:p)" + ), + ) + + # -- the run has rPr + endnoteReference; after removing ref, only rPr remains -- + doc_elm = element( + "w:document/w:body/w:p/w:r/(w:rPr/w:rStyle{w:val=EndnoteReference}" + ",w:endnoteReference{w:id=2})" + ) + document_part_ = Mock() + document_part_.element = doc_elm + endnotes_part_ = Mock() + endnotes_part_.part = endnotes_part_ + endnotes_part_._document_part = document_part_ + + endnote_elm = endnotes_elm.endnote_lst[2] + endnote = Endnote(endnote_elm, endnotes_part_) + + endnote.delete() + + # -- the entire run is removed since it only had rPr left -- + runs = doc_elm.xpath(".//w:r") + assert len(runs) == 0 + + def it_can_add_a_paragraph(self, endnotes_part_: Mock): + endnote_elm = cast(CT_Endnote, element("w:endnote{w:id=2}/w:p")) + endnote = Endnote(endnote_elm, endnotes_part_) + + paragraph = endnote.add_paragraph("New paragraph text") + + assert len(endnote.paragraphs) == 2 + assert endnote.paragraphs[1].text == "New paragraph text" + # -- default style is EndnoteText -- + assert paragraph._p.style == "EndnoteText" + + # -- fixtures -------------------------------------------------------------------------------- + + @pytest.fixture + def endnotes_part_(self, request: FixtureRequest): + return instance_mock(request, EndnotesPart) diff --git a/tests/test_files/macros.docm b/tests/test_files/macros.docm new file mode 100644 index 000000000..7cdbd12eb Binary files /dev/null and b/tests/test_files/macros.docm differ diff --git a/tests/test_files/python-icon.svg b/tests/test_files/python-icon.svg new file mode 100644 index 000000000..619be5f9b --- /dev/null +++ b/tests/test_files/python-icon.svg @@ -0,0 +1,5 @@ + + + + SVG + diff --git a/tests/test_files/snippets/inline.txt b/tests/test_files/snippets/inline.txt index 3b0d58148..107e82a30 100644 --- a/tests/test_files/snippets/inline.txt +++ b/tests/test_files/snippets/inline.txt @@ -1,5 +1,6 @@ - + + diff --git a/tests/test_footnotes.py b/tests/test_footnotes.py new file mode 100644 index 000000000..e3d963fe5 --- /dev/null +++ b/tests/test_footnotes.py @@ -0,0 +1,290 @@ +# pyright: reportPrivateUsage=false + +"""Unit test suite for the `docx.footnotes` module.""" + +from __future__ import annotations + +from typing import cast + +import pytest + +from docx.footnotes import Footnote, Footnotes +from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.packuri import PackURI +from docx.oxml.footnotes import CT_Footnote, CT_Footnotes +from docx.oxml.ns import qn +from docx.oxml.text.run import CT_R +from docx.package import Package +from docx.parts.footnotes import FootnotesPart +from docx.text.run import Run + +from .unitutil.cxml import element +from .unitutil.mock import FixtureRequest, Mock, instance_mock + + +class DescribeFootnotes: + """Unit-test suite for `docx.footnotes.Footnotes` objects.""" + + @pytest.mark.parametrize( + ("cxml", "count"), + [ + # -- empty footnotes (only separators) -- + ( + "w:footnotes/(w:footnote{w:id=0,w:type=separator}" + ",w:footnote{w:id=1,w:type=continuationSeparator})", + 0, + ), + # -- one user footnote -- + ( + "w:footnotes/(w:footnote{w:id=0,w:type=separator}" + ",w:footnote{w:id=1,w:type=continuationSeparator}" + ",w:footnote{w:id=2})", + 1, + ), + # -- two user footnotes -- + ( + "w:footnotes/(w:footnote{w:id=0,w:type=separator}" + ",w:footnote{w:id=1,w:type=continuationSeparator}" + ",w:footnote{w:id=2},w:footnote{w:id=3})", + 2, + ), + ], + ) + def it_knows_how_many_footnotes_it_contains(self, cxml: str, count: int, package_: Mock): + footnotes_elm = cast(CT_Footnotes, element(cxml)) + footnotes_part = FootnotesPart( + PackURI("/word/footnotes.xml"), CT.WML_FOOTNOTES, footnotes_elm, package_ + ) + footnotes = Footnotes(footnotes_elm, footnotes_part) + + assert len(footnotes) == count + + def it_is_iterable_over_user_footnotes(self, package_: Mock): + footnotes_elm = cast( + CT_Footnotes, + element( + "w:footnotes/(w:footnote{w:id=0,w:type=separator}" + ",w:footnote{w:id=1,w:type=continuationSeparator}" + ",w:footnote{w:id=2},w:footnote{w:id=3})" + ), + ) + footnotes_part = FootnotesPart( + PackURI("/word/footnotes.xml"), CT.WML_FOOTNOTES, footnotes_elm, package_ + ) + footnotes = Footnotes(footnotes_elm, footnotes_part) + + footnote_iter = iter(footnotes) + + fn1 = next(footnote_iter) + assert type(fn1) is Footnote + assert fn1.footnote_id == 2 + fn2 = next(footnote_iter) + assert type(fn2) is Footnote + assert fn2.footnote_id == 3 + with pytest.raises(StopIteration): + next(footnote_iter) + + def it_can_add_a_footnote(self, package_: Mock): + footnotes_elm = cast( + CT_Footnotes, + element( + "w:footnotes/(w:footnote{w:id=0,w:type=separator}" + ",w:footnote{w:id=1,w:type=continuationSeparator})" + ), + ) + footnotes_part = FootnotesPart( + PackURI("/word/footnotes.xml"), CT.WML_FOOTNOTES, footnotes_elm, package_ + ) + footnotes = Footnotes(footnotes_elm, footnotes_part) + + # -- create a run to anchor the footnote reference -- + para_elm = element("w:p/w:r") + r_elm = cast(CT_R, para_elm[0]) + run = Run(r_elm, footnotes_part) + + footnote = footnotes.add(run) + + # -- a Footnote is returned -- + assert isinstance(footnote, Footnote) + assert footnote.footnote_id == 2 + # -- the footnote part is linked -- + assert footnote.part is footnotes_part + # -- the footnote has a single paragraph with FootnoteText style -- + assert len(footnote.paragraphs) == 1 + assert footnote.paragraphs[0]._p.style == "FootnoteText" + # -- a footnoteReference was inserted into the run -- + ref_elms = r_elm.xpath("./w:footnoteReference") + assert len(ref_elms) == 1 + assert ref_elms[0].get(qn("w:id")) == "2" + # -- the run has FootnoteReference character style -- + assert r_elm.style == "FootnoteReference" + + def it_can_add_a_footnote_with_text(self, package_: Mock): + footnotes_elm = cast( + CT_Footnotes, + element( + "w:footnotes/(w:footnote{w:id=0,w:type=separator}" + ",w:footnote{w:id=1,w:type=continuationSeparator})" + ), + ) + footnotes_part = FootnotesPart( + PackURI("/word/footnotes.xml"), CT.WML_FOOTNOTES, footnotes_elm, package_ + ) + footnotes = Footnotes(footnotes_elm, footnotes_part) + + para_elm = element("w:p/w:r") + r_elm = cast(CT_R, para_elm[0]) + run = Run(r_elm, footnotes_part) + + footnote = footnotes.add(run, text="This is a footnote.") + + # -- the first paragraph has the text after the footnote ref run -- + first_para = footnote.paragraphs[0] + assert len(first_para._p.r_lst) == 2 + assert first_para._p.r_lst[1].text == "This is a footnote." + + # -- fixtures -------------------------------------------------------------------------------- + + @pytest.fixture + def package_(self, request: FixtureRequest): + return instance_mock(request, Package) + + +class DescribeFootnote: + """Unit-test suite for `docx.footnotes.Footnote`.""" + + def it_knows_its_footnote_id(self, footnotes_part_: Mock): + footnote_elm = cast(CT_Footnote, element("w:footnote{w:id=42}")) + footnote = Footnote(footnote_elm, footnotes_part_) + + assert footnote.footnote_id == 42 + + def it_provides_access_to_the_paragraphs_it_contains(self, footnotes_part_: Mock): + footnote_elm = cast( + CT_Footnote, + element('w:footnote{w:id=2}/(w:p/w:r/w:t"First para",w:p/w:r/w:t"Second para")'), + ) + footnote = Footnote(footnote_elm, footnotes_part_) + + paragraphs = footnote.paragraphs + + assert len(paragraphs) == 2 + assert [para.text for para in paragraphs] == ["First para", "Second para"] + + @pytest.mark.parametrize( + ("cxml", "expected_value"), + [ + ("w:footnote{w:id=2}", ""), + ('w:footnote{w:id=2}/w:p/w:r/w:t"Footnote text."', "Footnote text."), + ( + 'w:footnote{w:id=2}/(w:p/w:r/w:t"First para",w:p/w:r/w:t"Second para")', + "First para\nSecond para", + ), + ( + 'w:footnote{w:id=2}/(w:p/w:r/w:t"First para",w:p,w:p/w:r/w:t"Second para")', + "First para\n\nSecond para", + ), + ], + ) + def it_can_summarize_its_content_as_text( + self, cxml: str, expected_value: str, footnotes_part_: Mock + ): + assert Footnote(cast(CT_Footnote, element(cxml)), footnotes_part_).text == expected_value + + def it_can_clear_its_content(self, footnotes_part_: Mock): + footnote_elm = cast( + CT_Footnote, + element('w:footnote{w:id=2}/(w:p/w:r/w:t"First",w:p/w:r/w:t"Second")'), + ) + footnote = Footnote(footnote_elm, footnotes_part_) + assert len(footnote.paragraphs) == 2 + + result = footnote.clear() + + assert result is footnote + assert len(footnote.paragraphs) == 1 + p = footnote.paragraphs[0] + assert p.text == "" + assert p._p.style == "FootnoteText" + # -- the paragraph retains the footnoteRef run for the auto-number mark -- + assert len(p._p.r_lst) == 1 + assert p._p.r_lst[0].style == "FootnoteReference" + assert p._p.r_lst[0][-1].tag == qn("w:footnoteRef") + + def it_can_delete_itself(self): + # -- build a footnotes element with a user footnote (id=2) -- + footnotes_elm = cast( + CT_Footnotes, + element( + "w:footnotes/(w:footnote{w:id=0,w:type=separator}" + ",w:footnote{w:id=1,w:type=continuationSeparator}" + ',w:footnote{w:id=2}/w:p/w:r/w:t"Footnote text")' + ), + ) + + # -- build a document element containing the footnoteReference -- + doc_elm = element("w:document/w:body/w:p/w:r/w:footnoteReference{w:id=2}") + document_part_ = Mock() + document_part_.element = doc_elm + footnotes_part_ = Mock() + footnotes_part_.part = footnotes_part_ + footnotes_part_._document_part = document_part_ + + footnote_elm = footnotes_elm.footnote_lst[2] + footnote = Footnote(footnote_elm, footnotes_part_) + + footnote.delete() + + # -- the footnote element is removed from the footnotes part -- + assert len(footnotes_elm.footnote_lst) == 2 + assert all(fn.type is not None for fn in footnotes_elm.footnote_lst) + # -- the footnoteReference run is removed from the document body -- + refs = doc_elm.xpath(".//w:footnoteReference") + assert len(refs) == 0 + + def it_removes_the_ref_run_when_deleting_if_run_becomes_empty(self): + footnotes_elm = cast( + CT_Footnotes, + element( + "w:footnotes/(w:footnote{w:id=0,w:type=separator}" + ",w:footnote{w:id=1,w:type=continuationSeparator}" + ",w:footnote{w:id=2}/w:p)" + ), + ) + + # -- the run has rPr + footnoteReference; after removing ref, only rPr remains -- + doc_elm = element( + "w:document/w:body/w:p/w:r/(w:rPr/w:rStyle{w:val=FootnoteReference}" + ",w:footnoteReference{w:id=2})" + ) + document_part_ = Mock() + document_part_.element = doc_elm + footnotes_part_ = Mock() + footnotes_part_.part = footnotes_part_ + footnotes_part_._document_part = document_part_ + + footnote_elm = footnotes_elm.footnote_lst[2] + footnote = Footnote(footnote_elm, footnotes_part_) + + footnote.delete() + + # -- the entire run is removed since it only had rPr left -- + runs = doc_elm.xpath(".//w:r") + assert len(runs) == 0 + + def it_can_add_a_paragraph(self, footnotes_part_: Mock): + footnote_elm = cast(CT_Footnote, element("w:footnote{w:id=2}/w:p")) + footnote = Footnote(footnote_elm, footnotes_part_) + + paragraph = footnote.add_paragraph("New paragraph text") + + assert len(footnote.paragraphs) == 2 + assert footnote.paragraphs[1].text == "New paragraph text" + # -- default style is FootnoteText -- + assert paragraph._p.style == "FootnoteText" + + # -- fixtures -------------------------------------------------------------------------------- + + @pytest.fixture + def footnotes_part_(self, request: FixtureRequest): + return instance_mock(request, FootnotesPart) diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 000000000..a888125e2 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,405 @@ +# pyright: reportPrivateUsage=false + +"""Unit test suite for the `docx.search` module.""" + +from __future__ import annotations + +from typing import cast + +import pytest + +from docx.document import Document +from docx.oxml.document import CT_Document +from docx.search import ( + SearchMatch, + _build_char_map, + replace_in_paragraphs, + search_paragraphs, +) +from docx.text.paragraph import Paragraph + +from .unitutil.cxml import element +from .unitutil.mock import Mock + + +class DescribeSearchMatch: + """Unit-test suite for `docx.search.SearchMatch` objects.""" + + def it_provides_access_to_its_properties(self): + paragraph_ = Mock(spec=Paragraph) + match = SearchMatch( + paragraph=paragraph_, + paragraph_index=2, + run_indices=[0, 1], + start=5, + end=10, + ) + assert match.paragraph is paragraph_ + assert match.paragraph_index == 2 + assert match.run_indices == [0, 1] + assert match.start == 5 + assert match.end == 10 + + +class DescribeSearch: + """Unit-test suite for `docx.search.search_paragraphs`.""" + + def it_finds_text_in_a_single_run(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"hello world"'), + ) + doc = Document(document_elm, Mock()) + paragraphs = doc.paragraphs + + matches = search_paragraphs(paragraphs, "world") + + assert len(matches) == 1 + assert matches[0].paragraph_index == 0 + assert matches[0].start == 6 + assert matches[0].end == 11 + assert matches[0].run_indices == [0] + + def it_finds_text_spanning_multiple_runs(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/(w:r/w:t"hel",w:r/w:t"lo world")'), + ) + doc = Document(document_elm, Mock()) + paragraphs = doc.paragraphs + + matches = search_paragraphs(paragraphs, "hello") + + assert len(matches) == 1 + assert matches[0].run_indices == [0, 1] + assert matches[0].start == 0 + assert matches[0].end == 5 + + def it_finds_multiple_matches_in_one_paragraph(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"foo bar foo"'), + ) + doc = Document(document_elm, Mock()) + + matches = search_paragraphs(doc.paragraphs, "foo") + + assert len(matches) == 2 + assert matches[0].start == 0 + assert matches[0].end == 3 + assert matches[1].start == 8 + assert matches[1].end == 11 + + def it_finds_matches_across_multiple_paragraphs(self): + document_elm = cast( + CT_Document, + element( + "w:document/w:body/" + '(w:p/w:r/w:t"hello"' + ',w:p/w:r/w:t"world"' + ',w:p/w:r/w:t"hello again")' + ), + ) + doc = Document(document_elm, Mock()) + + matches = search_paragraphs(doc.paragraphs, "hello") + + assert len(matches) == 2 + assert matches[0].paragraph_index == 0 + assert matches[1].paragraph_index == 2 + + def it_returns_empty_list_when_no_match(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"hello"'), + ) + doc = Document(document_elm, Mock()) + + matches = search_paragraphs(doc.paragraphs, "xyz") + + assert matches == [] + + def it_returns_empty_list_for_empty_search_text(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"hello"'), + ) + doc = Document(document_elm, Mock()) + + matches = search_paragraphs(doc.paragraphs, "") + + assert matches == [] + + def it_supports_case_insensitive_search(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"Hello World"'), + ) + doc = Document(document_elm, Mock()) + + matches = search_paragraphs(doc.paragraphs, "hello", case_sensitive=False) + + assert len(matches) == 1 + assert matches[0].start == 0 + assert matches[0].end == 5 + + def it_supports_case_sensitive_search_by_default(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"Hello World"'), + ) + doc = Document(document_elm, Mock()) + + matches = search_paragraphs(doc.paragraphs, "hello") + + assert matches == [] + + def it_supports_whole_word_search(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"cat concatenate the cat"'), + ) + doc = Document(document_elm, Mock()) + + matches = search_paragraphs(doc.paragraphs, "cat", whole_word=True) + + assert len(matches) == 2 + assert matches[0].start == 0 + assert matches[0].end == 3 + assert matches[1].start == 20 + assert matches[1].end == 23 + + def it_handles_paragraph_with_no_runs(self): + document_elm = cast( + CT_Document, + element("w:document/w:body/w:p"), + ) + doc = Document(document_elm, Mock()) + + matches = search_paragraphs(doc.paragraphs, "text") + + assert matches == [] + + +class DescribeReplace: + """Unit-test suite for `docx.search.replace_in_paragraphs`.""" + + def it_replaces_text_in_a_single_run(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"hello world"'), + ) + doc = Document(document_elm, Mock()) + + count = replace_in_paragraphs(doc.paragraphs, "world", "there") + + assert count == 1 + assert doc.paragraphs[0].text == "hello there" + + def it_replaces_text_spanning_multiple_runs(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/(w:r/w:t"hel",w:r/w:t"lo world")'), + ) + doc = Document(document_elm, Mock()) + + count = replace_in_paragraphs(doc.paragraphs, "hello", "hi") + + assert count == 1 + # First run gets the replacement text, second run loses the matched portion. + assert doc.paragraphs[0].runs[0].text == "hi" + assert doc.paragraphs[0].runs[1].text == " world" + + def it_replaces_multiple_occurrences(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"foo bar foo"'), + ) + doc = Document(document_elm, Mock()) + + count = replace_in_paragraphs(doc.paragraphs, "foo", "baz") + + assert count == 2 + assert doc.paragraphs[0].text == "baz bar baz" + + def it_replaces_across_multiple_paragraphs(self): + document_elm = cast( + CT_Document, + element( + "w:document/w:body/" + '(w:p/w:r/w:t"hello"' + ',w:p/w:r/w:t"world"' + ',w:p/w:r/w:t"hello")' + ), + ) + doc = Document(document_elm, Mock()) + + count = replace_in_paragraphs(doc.paragraphs, "hello", "hi") + + assert count == 2 + assert doc.paragraphs[0].text == "hi" + assert doc.paragraphs[1].text == "world" + assert doc.paragraphs[2].text == "hi" + + def it_preserves_formatting_of_first_run(self): + document_elm = cast( + CT_Document, + element( + "w:document/w:body/w:p/" + "(w:r/(w:rPr/w:b,w:t\"hel\")" + ",w:r/(w:rPr/w:i,w:t\"lo world\"))" + ), + ) + doc = Document(document_elm, Mock()) + + replace_in_paragraphs(doc.paragraphs, "hello", "hi") + + # First run keeps its bold formatting. + assert doc.paragraphs[0].runs[0].bold is True + assert doc.paragraphs[0].runs[0].text == "hi" + # Second run keeps its italic formatting. + assert doc.paragraphs[0].runs[1].italic is True + assert doc.paragraphs[0].runs[1].text == " world" + + def it_handles_replacement_with_longer_text(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"hi"'), + ) + doc = Document(document_elm, Mock()) + + count = replace_in_paragraphs(doc.paragraphs, "hi", "hello world") + + assert count == 1 + assert doc.paragraphs[0].text == "hello world" + + def it_handles_replacement_with_empty_text(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"hello world"'), + ) + doc = Document(document_elm, Mock()) + + count = replace_in_paragraphs(doc.paragraphs, "world", "") + + assert count == 1 + assert doc.paragraphs[0].text == "hello " + + def it_returns_zero_when_no_match(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"hello"'), + ) + doc = Document(document_elm, Mock()) + + count = replace_in_paragraphs(doc.paragraphs, "xyz", "abc") + + assert count == 0 + assert doc.paragraphs[0].text == "hello" + + def it_returns_zero_for_empty_old_text(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"hello"'), + ) + doc = Document(document_elm, Mock()) + + count = replace_in_paragraphs(doc.paragraphs, "", "abc") + + assert count == 0 + + def it_supports_case_insensitive_replace(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"Hello HELLO hello"'), + ) + doc = Document(document_elm, Mock()) + + count = replace_in_paragraphs( + doc.paragraphs, "hello", "hi", case_sensitive=False + ) + + assert count == 3 + assert doc.paragraphs[0].text == "hi hi hi" + + def it_supports_whole_word_replace(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"cat concatenate the cat"'), + ) + doc = Document(document_elm, Mock()) + + count = replace_in_paragraphs( + doc.paragraphs, "cat", "dog", whole_word=True + ) + + assert count == 2 + assert doc.paragraphs[0].text == "dog concatenate the dog" + + def it_replaces_text_spanning_three_runs(self): + document_elm = cast( + CT_Document, + element( + "w:document/w:body/w:p/" + '(w:r/w:t"ab",w:r/w:t"cd",w:r/w:t"ef")' + ), + ) + doc = Document(document_elm, Mock()) + + count = replace_in_paragraphs(doc.paragraphs, "bcde", "X") + + assert count == 1 + assert doc.paragraphs[0].runs[0].text == "aX" + assert doc.paragraphs[0].runs[1].text == "" + assert doc.paragraphs[0].runs[2].text == "f" + + +class DescribeDocumentSearchAndReplace: + """Unit-test suite for Document.search() and Document.replace().""" + + def it_exposes_search_on_document(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"hello world"'), + ) + doc = Document(document_elm, Mock()) + + matches = doc.search("world") + + assert len(matches) == 1 + assert matches[0].start == 6 + + def it_exposes_replace_on_document(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"hello world"'), + ) + doc = Document(document_elm, Mock()) + + count = doc.replace("world", "there") + + assert count == 1 + assert doc.paragraphs[0].text == "hello there" + + def it_passes_options_through_to_search(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"Hello HELLO"'), + ) + doc = Document(document_elm, Mock()) + + matches = doc.search("hello", case_sensitive=False) + + assert len(matches) == 2 + + def it_passes_options_through_to_replace(self): + document_elm = cast( + CT_Document, + element('w:document/w:body/w:p/w:r/w:t"cat concatenate"'), + ) + doc = Document(document_elm, Mock()) + + count = doc.replace("cat", "dog", whole_word=True) + + assert count == 1 + assert doc.paragraphs[0].text == "dog concatenate" diff --git a/tests/test_section.py b/tests/test_section.py index 54d665768..50d93edbe 100644 --- a/tests/test_section.py +++ b/tests/test_section.py @@ -14,8 +14,8 @@ from docx.oxml.section import CT_SectPr from docx.parts.document import DocumentPart from docx.parts.hdrftr import FooterPart, HeaderPart -from docx.section import Section, Sections, _BaseHeaderFooter, _Footer, _Header -from docx.shared import Inches, Length +from docx.section import Column, Section, SectionColumns, Sections, _BaseHeaderFooter, _Footer, _Header +from docx.shared import Inches, Length, Twips from docx.table import Table from docx.text.paragraph import Paragraph @@ -543,6 +543,182 @@ def header_(self, request: FixtureRequest): return instance_mock(request, _Header) +class DescribeSectionColumns: + """Unit-test suite for `docx.section.SectionColumns`.""" + + @pytest.mark.parametrize( + ("sectPr_cxml", "expected_count"), + [ + ("w:sectPr", 1), + ("w:sectPr/w:cols", 1), + ("w:sectPr/w:cols{w:num=2}", 2), + ("w:sectPr/w:cols{w:num=3}", 3), + ], + ) + def it_knows_its_column_count(self, sectPr_cxml: str, expected_count: int): + sectPr = cast(CT_SectPr, element(sectPr_cxml)) + columns = SectionColumns(sectPr) + assert columns.count == expected_count + + @pytest.mark.parametrize( + ("sectPr_cxml", "value", "expected_cxml"), + [ + ("w:sectPr", 2, "w:sectPr/w:cols{w:num=2}"), + ("w:sectPr/w:cols{w:num=1}", 3, "w:sectPr/w:cols{w:num=3}"), + ], + ) + def it_can_change_its_column_count( + self, sectPr_cxml: str, value: int, expected_cxml: str + ): + sectPr = cast(CT_SectPr, element(sectPr_cxml)) + columns = SectionColumns(sectPr) + columns.count = value + assert sectPr.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("sectPr_cxml", "expected_value"), + [ + ("w:sectPr", True), + ("w:sectPr/w:cols", True), + ("w:sectPr/w:cols{w:equalWidth=1}", True), + ("w:sectPr/w:cols{w:equalWidth=0}", False), + ], + ) + def it_knows_whether_columns_have_equal_width( + self, sectPr_cxml: str, expected_value: bool + ): + sectPr = cast(CT_SectPr, element(sectPr_cxml)) + columns = SectionColumns(sectPr) + assert columns.equal_width is expected_value + + @pytest.mark.parametrize( + ("sectPr_cxml", "value", "expected_cxml"), + [ + ("w:sectPr", True, "w:sectPr/w:cols{w:equalWidth=1}"), + ("w:sectPr/w:cols{w:equalWidth=1}", False, "w:sectPr/w:cols{w:equalWidth=0}"), + ], + ) + def it_can_change_equal_width( + self, sectPr_cxml: str, value: bool, expected_cxml: str + ): + sectPr = cast(CT_SectPr, element(sectPr_cxml)) + columns = SectionColumns(sectPr) + columns.equal_width = value + assert sectPr.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("sectPr_cxml", "expected_value"), + [ + ("w:sectPr", None), + ("w:sectPr/w:cols", None), + ("w:sectPr/w:cols{w:space=720}", Twips(720)), + ], + ) + def it_knows_its_default_space(self, sectPr_cxml: str, expected_value: Length | None): + sectPr = cast(CT_SectPr, element(sectPr_cxml)) + columns = SectionColumns(sectPr) + assert columns.space == expected_value + + @pytest.mark.parametrize( + ("sectPr_cxml", "value", "expected_cxml"), + [ + ("w:sectPr", Twips(720), "w:sectPr/w:cols{w:space=720}"), + ("w:sectPr/w:cols{w:space=720}", None, "w:sectPr/w:cols"), + ], + ) + def it_can_change_its_default_space( + self, sectPr_cxml: str, value: Length | None, expected_cxml: str + ): + sectPr = cast(CT_SectPr, element(sectPr_cxml)) + columns = SectionColumns(sectPr) + columns.space = value + assert sectPr.xml == xml(expected_cxml) + + def it_provides_access_to_individual_columns(self): + sectPr = cast( + CT_SectPr, + element("w:sectPr/w:cols/(w:col{w:w=4320,w:space=720},w:col{w:w=4320})"), + ) + columns = SectionColumns(sectPr) + assert len(columns) == 2 + assert columns[0].width == Twips(4320) + assert columns[0].space == Twips(720) + assert columns[1].width == Twips(4320) + assert columns[1].space is None + + def it_can_iterate_over_individual_columns(self): + sectPr = cast( + CT_SectPr, + element("w:sectPr/w:cols/(w:col{w:w=4320,w:space=720},w:col{w:w=2880})"), + ) + columns = SectionColumns(sectPr) + col_list = list(columns) + assert len(col_list) == 2 + assert col_list[0].width == Twips(4320) + assert col_list[1].width == Twips(2880) + + def it_returns_zero_length_when_no_cols_element(self): + sectPr = cast(CT_SectPr, element("w:sectPr")) + columns = SectionColumns(sectPr) + assert len(columns) == 0 + assert list(columns) == [] + + +class DescribeColumn: + """Unit-test suite for `docx.section.Column`.""" + + @pytest.mark.parametrize( + ("col_cxml", "expected_width", "expected_space"), + [ + ("w:col", None, None), + ("w:col{w:w=4320,w:space=720}", Twips(4320), Twips(720)), + ], + ) + def it_knows_its_width_and_space( + self, col_cxml: str, expected_width: Length | None, expected_space: Length | None + ): + from docx.oxml.section import CT_Col + + col_elm = cast(CT_Col, element(col_cxml)) + col = Column(col_elm) + assert col.width == expected_width + assert col.space == expected_space + + def it_can_change_its_width(self): + from docx.oxml.section import CT_Col + + col_elm = cast(CT_Col, element("w:col")) + col = Column(col_elm) + col.width = Twips(4320) + assert col_elm.xml == xml("w:col{w:w=4320}") + + def it_can_change_its_space(self): + from docx.oxml.section import CT_Col + + col_elm = cast(CT_Col, element("w:col")) + col = Column(col_elm) + col.space = Twips(720) + assert col_elm.xml == xml("w:col{w:space=720}") + + +class DescribeSection_columns: + """Unit-test suite for `docx.section.Section.columns`.""" + + def it_provides_access_to_section_columns(self, document_part_: Mock): + sectPr = cast(CT_SectPr, element("w:sectPr/w:cols{w:num=2,w:space=720}")) + section = Section(sectPr, document_part_) + columns = section.columns + assert isinstance(columns, SectionColumns) + assert columns.count == 2 + assert columns.space == Twips(720) + + # -- fixtures----------------------------------------------------- + + @pytest.fixture + def document_part_(self, request: FixtureRequest): + return instance_mock(request, DocumentPart) + + class Describe_BaseHeaderFooter: """Unit-test suite for `docx.section._BaseHeaderFooter`.""" diff --git a/tests/test_settings.py b/tests/test_settings.py index ff07eda26..1204cde5d 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -4,9 +4,12 @@ from __future__ import annotations +import warnings + import pytest from docx.settings import Settings +from docx.shared import Twips from .unitutil.cxml import element, xml @@ -27,7 +30,9 @@ class DescribeSettings: def it_knows_when_the_document_has_distinct_odd_and_even_headers( self, cxml: str, expected_value: bool ): - assert Settings(element(cxml)).odd_and_even_pages_header_footer is expected_value + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + assert Settings(element(cxml)).odd_and_even_pages_header_footer is expected_value @pytest.mark.parametrize( ("cxml", "new_value", "expected_cxml"), @@ -43,6 +48,158 @@ def it_can_change_whether_the_document_has_distinct_odd_and_even_headers( ): settings = Settings(element(cxml)) - settings.odd_and_even_pages_header_footer = new_value + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + settings.odd_and_even_pages_header_footer = new_value + + assert settings._settings.xml == xml(expected_cxml) + + def it_emits_deprecation_warning_for_odd_and_even_pages_header_footer(self): + settings = Settings(element("w:settings/w:evenAndOddHeaders")) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + settings.odd_and_even_pages_header_footer + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "even_and_odd_headers" in str(w[0].message) + + @pytest.mark.parametrize( + ("cxml", "expected_value"), + [ + ("w:settings", False), + ("w:settings/w:evenAndOddHeaders", True), + ("w:settings/w:evenAndOddHeaders{w:val=0}", False), + ], + ) + def it_provides_even_and_odd_headers(self, cxml: str, expected_value: bool): + assert Settings(element(cxml)).even_and_odd_headers is expected_value + + @pytest.mark.parametrize( + ("cxml", "new_value", "expected_cxml"), + [ + ("w:settings", True, "w:settings/w:evenAndOddHeaders"), + ("w:settings/w:evenAndOddHeaders", False, "w:settings"), + ], + ) + def it_can_change_even_and_odd_headers( + self, cxml: str, new_value: bool, expected_cxml: str + ): + settings = Settings(element(cxml)) + settings.even_and_odd_headers = new_value + assert settings._settings.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("cxml", "expected_value"), + [ + ("w:settings", None), + ("w:settings/w:zoom{w:percent=100}", 100), + ("w:settings/w:zoom{w:percent=75}", 75), + ], + ) + def it_can_get_the_zoom_percent(self, cxml: str, expected_value: int | None): + assert Settings(element(cxml)).zoom_percent == expected_value + @pytest.mark.parametrize( + ("cxml", "new_value", "expected_cxml"), + [ + ("w:settings", 100, "w:settings/w:zoom{w:percent=100}"), + ("w:settings/w:zoom{w:percent=75}", 150, "w:settings/w:zoom{w:percent=150}"), + ("w:settings/w:zoom{w:percent=100}", None, "w:settings"), + ], + ) + def it_can_set_the_zoom_percent( + self, cxml: str, new_value: int | None, expected_cxml: str + ): + settings = Settings(element(cxml)) + settings.zoom_percent = new_value assert settings._settings.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("cxml", "expected_value"), + [ + ("w:settings", False), + ("w:settings/w:trackRevisions", True), + ("w:settings/w:trackRevisions{w:val=0}", False), + ], + ) + def it_can_get_track_revisions(self, cxml: str, expected_value: bool): + assert Settings(element(cxml)).track_revisions is expected_value + + @pytest.mark.parametrize( + ("cxml", "new_value", "expected_cxml"), + [ + ("w:settings", True, "w:settings/w:trackRevisions"), + ("w:settings/w:trackRevisions", False, "w:settings"), + ], + ) + def it_can_set_track_revisions( + self, cxml: str, new_value: bool, expected_cxml: str + ): + settings = Settings(element(cxml)) + settings.track_revisions = new_value + assert settings._settings.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("cxml", "expected_value"), + [ + ("w:settings", None), + ("w:settings/w:defaultTabStop{w:val=720}", Twips(720)), + ], + ) + def it_can_get_the_default_tab_stop(self, cxml: str, expected_value): + assert Settings(element(cxml)).default_tab_stop == expected_value + + @pytest.mark.parametrize( + ("cxml", "new_value", "expected_cxml"), + [ + ("w:settings", Twips(720), "w:settings/w:defaultTabStop{w:val=720}"), + ("w:settings/w:defaultTabStop{w:val=720}", None, "w:settings"), + ], + ) + def it_can_set_the_default_tab_stop( + self, cxml: str, new_value, expected_cxml: str + ): + settings = Settings(element(cxml)) + settings.default_tab_stop = new_value + assert settings._settings.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("cxml", "expected_type", "expected_enabled"), + [ + ("w:settings", None, False), + ( + "w:settings/w:documentProtection{w:edit=readOnly,w:enforcement=1}", + "readOnly", + True, + ), + ( + "w:settings/w:documentProtection{w:edit=comments,w:enforcement=0}", + "comments", + False, + ), + ], + ) + def it_can_get_document_protection( + self, + cxml: str, + expected_type: str | None, + expected_enabled: bool, + ): + protection = Settings(element(cxml)).document_protection + assert protection.type == expected_type + assert protection.enabled is expected_enabled + + def it_can_get_the_compatibility_mode(self): + settings = Settings(element("w:settings")) + assert settings.compatibility_mode is None + + def it_can_set_the_compatibility_mode(self): + settings = Settings(element("w:settings")) + settings.compatibility_mode = 15 + assert settings.compatibility_mode == 15 + + def it_can_remove_the_compatibility_mode(self): + settings = Settings(element("w:settings")) + settings.compatibility_mode = 15 + settings.compatibility_mode = None + assert settings.compatibility_mode is None diff --git a/tests/test_strategy.py b/tests/test_strategy.py new file mode 100644 index 000000000..33aa8a3ff --- /dev/null +++ b/tests/test_strategy.py @@ -0,0 +1,373 @@ +# pyright: reportPrivateUsage=false +# pyright: reportUnknownMemberType=false + +"""Multi-layered testing strategy exercising all five validation layers. + +This module establishes the testing patterns described in issue #43 and provides +at least one example test for each layer, using the comments feature as the +reference implementation. + +Layer 1: XML Structure Tests — validates python-docx produces correct OOXML elements +Layer 2: OOXML Schema Validation — validates output against XSD schemas +Layer 3: Round-Trip Tests — write/save/reopen/assert pattern +Layer 4: Reference File Comparison — validates reading of Word-created .docx files +Layer 5: LibreOffice Headless Validation — optional CI validation via conversion +""" + +from __future__ import annotations + +import os +import shutil +import tempfile +import zipfile +from typing import cast + +import pytest + +from docx import Document +from docx.document import Document as DocumentCls +from docx.oxml.ns import qn + +from tests.helpers.libreoffice import is_libreoffice_available, validate_with_libreoffice +from tests.helpers.refcmp import compare_xml_structure, ref_docx_exists, ref_docx_path +from tests.helpers.roundtrip import assert_round_trip, save_and_reopen +from tests.helpers.schema import ( + SchemaValidationResult, + load_bundled_schema, + validate_docx_xml_parts, + validate_part_xml, +) +from tests.helpers.validate import ( + validate_content_type_present, + validate_elements_present, + validate_ooxml_structure, + validate_relationship_present, +) +from tests.helpers.xmlparse import parse_docx_xml + + +# ===================================================================================== +# Layer 1: XML Structure Tests +# ===================================================================================== + + +class DescribeLayer1_XMLStructure: + """Layer 1: Validate that python-docx produces correct OOXML elements.""" + + def it_produces_a_comments_part_with_correct_elements(self, tmp_docx_path: str): + doc = Document() + doc.add_paragraph("Test paragraph.") + run = doc.paragraphs[0].runs[0] + doc.add_comment(run, text="A test comment.", author="Test Author", initials="TA") + + doc.save(tmp_docx_path) + + # -- word/comments.xml contains w:comment elements -- + comments_xml = parse_docx_xml(tmp_docx_path, "word/comments.xml") + assert comments_xml is not None, "word/comments.xml should exist" + comment_elms = comments_xml.findall(qn("w:comment")) + assert len(comment_elms) >= 1, "should have at least one w:comment element" + + # -- comment has required attributes -- + comment = comment_elms[0] + assert comment.get(qn("w:id")) is not None, "w:id attribute required" + assert comment.get(qn("w:author")) == "Test Author" + assert comment.get(qn("w:initials")) == "TA" + assert comment.get(qn("w:date")) is not None, "w:date attribute expected" + + # -- document.xml contains comment range markers -- + doc_xml = parse_docx_xml(tmp_docx_path, "word/document.xml") + assert doc_xml is not None + range_starts = doc_xml.iter(qn("w:commentRangeStart")) + range_ends = doc_xml.iter(qn("w:commentRangeEnd")) + assert len(list(range_starts)) >= 1, "should have commentRangeStart marker" + assert len(list(range_ends)) >= 1, "should have commentRangeEnd marker" + + def it_registers_comments_content_type(self, tmp_docx_path: str): + doc = Document() + doc.add_paragraph("Test.") + doc.add_comment(doc.paragraphs[0].runs[0], text="Comment") + + doc.save(tmp_docx_path) + + ct = "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml" + assert validate_content_type_present(tmp_docx_path, ct), ( + f"Content type '{ct}' should be in [Content_Types].xml" + ) + + def it_registers_comments_relationship(self, tmp_docx_path: str): + doc = Document() + doc.add_paragraph("Test.") + doc.add_comment(doc.paragraphs[0].runs[0], text="Comment") + + doc.save(tmp_docx_path) + + rel_type = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" + ) + assert validate_relationship_present(tmp_docx_path, rel_type), ( + f"Relationship type '{rel_type}' should be in document.xml.rels" + ) + + +# ===================================================================================== +# Layer 2: OOXML Schema Validation +# ===================================================================================== + + +class DescribeLayer2_SchemaValidation: + """Layer 2: Validate output XML against OOXML schemas.""" + + def it_produces_structurally_valid_docx_files(self, tmp_docx_path: str): + doc = Document() + doc.add_paragraph("Hello, World!") + + doc.save(tmp_docx_path) + errors = validate_ooxml_structure(tmp_docx_path) + assert errors == [], f"Structural validation errors: {errors}" + + def it_produces_structurally_valid_docx_with_comments(self, tmp_docx_path: str): + doc = Document() + doc.add_paragraph("Test paragraph.") + doc.add_comment(doc.paragraphs[0].runs[0], text="Comment", author="Author") + + doc.save(tmp_docx_path) + errors = validate_ooxml_structure(tmp_docx_path) + assert errors == [], f"Structural validation errors: {errors}" + + def it_produces_well_formed_xml_in_all_parts(self, tmp_docx_path: str): + doc = Document() + doc.add_paragraph("Test.") + doc.add_comment(doc.paragraphs[0].runs[0], text="Comment", author="A") + + doc.save(tmp_docx_path) + results = validate_docx_xml_parts(tmp_docx_path) + for part_name, result in results.items(): + assert result.is_valid, ( + f"XML part '{part_name}' is malformed: {result.errors}" + ) + + def it_validates_comments_xml_against_schema(self, tmp_docx_path: str): + schema = load_bundled_schema("wml-comments") + if schema is None: + pytest.skip("Comments schema not available") + + doc = Document() + doc.add_paragraph("Test.") + doc.add_comment(doc.paragraphs[0].runs[0], text="A comment", author="Author") + + doc.save(tmp_docx_path) + + with zipfile.ZipFile(tmp_docx_path) as zf: + comments_bytes = zf.read("word/comments.xml") + + result = validate_part_xml(comments_bytes, schema) + assert result.is_valid, f"Schema validation errors: {result.errors}" + + +# ===================================================================================== +# Layer 3: Round-Trip Tests +# ===================================================================================== + + +class DescribeLayer3_RoundTrip: + """Layer 3: Write → Save → Reopen → Assert pattern.""" + + def it_round_trips_a_simple_comment(self): + def create(doc: DocumentCls) -> dict[str, str]: + doc.add_paragraph("Annotated text.") + run = doc.paragraphs[0].runs[0] + doc.add_comment(run, text="My comment", author="Jane Doe", initials="JD") + return {"text": "My comment", "author": "Jane Doe", "initials": "JD"} + + def check(doc: DocumentCls, ctx: dict[str, str]) -> None: + comments = doc.comments + comments_list = list(comments) + assert len(comments_list) >= 1 + comment = comments_list[0] + assert comment.text == ctx["text"] + assert comment.author == ctx["author"] + assert comment.initials == ctx["initials"] + + assert_round_trip(create, check) + + def it_round_trips_threaded_comments(self): + def create(doc: DocumentCls) -> dict[str, str]: + doc.add_paragraph("Threaded comment test.") + run = doc.paragraphs[0].runs[0] + comment = doc.add_comment(run, text="Parent comment", author="Author A") + comment.add_reply(text="Reply 1", author="Author B") + comment.add_reply(text="Reply 2", author="Author C") + return {"parent": "Parent comment", "reply1": "Reply 1", "reply2": "Reply 2"} + + def check(doc: DocumentCls, ctx: dict[str, str]) -> None: + comments = doc.comments + assert len(comments) >= 3 + comments_list = list(comments) + parent = comments_list[0] + assert parent.text == ctx["parent"] + replies = parent.replies + assert len(replies) == 2 + assert replies[0].text == ctx["reply1"] + assert replies[1].text == ctx["reply2"] + + assert_round_trip(create, check) + + def it_round_trips_comment_on_specific_text_range(self): + def create(doc: DocumentCls) -> str: + para = doc.add_paragraph() + para.add_run("Before ") + run2 = para.add_run("target text") + para.add_run(" after") + doc.add_comment(run2, text="Comment on target", author="Tester") + return "target text" + + def check(doc: DocumentCls, target_text: str) -> None: + comments = doc.comments + comments_list = list(comments) + assert len(comments_list) >= 1 + comment = comments_list[0] + assert comment.text == "Comment on target" + assert comment.author == "Tester" + + assert_round_trip(create, check) + + def it_round_trips_multiple_comments_by_different_authors(self): + doc = Document() + para = doc.add_paragraph("Multiple authors.") + run = para.runs[0] + doc.add_comment(run, text="Comment 1", author="Alice", initials="A") + doc.add_comment(run, text="Comment 2", author="Bob", initials="B") + doc.add_comment(run, text="Comment 3", author="Carol", initials="C") + + doc2 = save_and_reopen(doc) + + comments = doc2.comments + assert len(comments) == 3 + authors = {c.author for c in comments} + assert authors == {"Alice", "Bob", "Carol"} + + def it_round_trips_a_comment_with_multiline_text(self): + doc = Document() + doc.add_paragraph("Multi-line comment test.") + run = doc.paragraphs[0].runs[0] + doc.add_comment(run, text="Line 1\nLine 2\nLine 3", author="Author") + + doc2 = save_and_reopen(doc) + + comments_list = list(doc2.comments) + assert len(comments_list) >= 1 + comment = comments_list[0] + assert len(comment.paragraphs) == 3 + assert comment.paragraphs[0].text == "Line 1" + assert comment.paragraphs[1].text == "Line 2" + assert comment.paragraphs[2].text == "Line 3" + + +# ===================================================================================== +# Layer 4: Reference File Comparison +# ===================================================================================== + + +class DescribeLayer4_ReferenceComparison: + """Layer 4: Read reference .docx files created in Microsoft Word.""" + + def it_reads_existing_comments_fixture(self): + # -- Use the existing acceptance test fixture that has comments -- + fixture_path = os.path.join( + os.path.dirname(__file__), + "..", + "features", + "steps", + "test_files", + "comments-rich-para.docx", + ) + if not os.path.exists(fixture_path): + pytest.skip("comments-rich-para.docx fixture not available") + + doc = Document(fixture_path) + comments = doc.comments + assert len(comments) > 0, "Reference file should contain comments" + + first_comment = next(iter(comments)) + assert first_comment.author != "", "Comment should have an author" + assert first_comment.comment_id is not None + + def it_can_compare_xml_structure_of_generated_vs_reference(self, tmp_docx_path: str): + """Pattern test: demonstrates how to compare generated output against a reference.""" + # -- Generate a doc -- + doc = Document() + doc.add_paragraph("Comparison test.") + + fd2, ref_path = tempfile.mkstemp(suffix=".docx") + os.close(fd2) + + try: + doc.save(tmp_docx_path) + # -- Use the same doc as "reference" for this pattern demonstration -- + doc.save(ref_path) + + # -- Compare word/document.xml structure -- + diffs = compare_xml_structure( + tmp_docx_path, + ref_path, + "word/document.xml", + ignore_attrs={qn("w:id")}, + ) + assert diffs == [], f"Structural differences found: {diffs}" + finally: + os.unlink(ref_path) + + def it_reads_a_reference_comments_doc_when_available(self): + if not ref_docx_exists("comments-simple"): + pytest.skip( + "Reference file 'comments-simple.docx' not yet created. " + "See tests/ref-docs/README.md for instructions." + ) + + doc = Document(ref_docx_path("comments-simple")) + comments = doc.comments + assert len(comments) >= 1 + + +# ===================================================================================== +# Layer 5: LibreOffice Headless Validation +# ===================================================================================== + + +class DescribeLayer5_LibreOfficeValidation: + """Layer 5: Validate .docx files by converting to PDF with LibreOffice.""" + + @pytest.mark.libreoffice + def it_converts_a_basic_document_to_pdf(self, tmp_docx_path: str): + if not is_libreoffice_available(): + pytest.skip("LibreOffice not available") + + doc = Document() + doc.add_paragraph("LibreOffice validation test.") + + doc.save(tmp_docx_path) + pdf_path, outdir = validate_with_libreoffice(tmp_docx_path) + try: + assert os.path.exists(pdf_path), "PDF should have been created" + assert os.path.getsize(pdf_path) > 0, "PDF should not be empty" + finally: + shutil.rmtree(outdir, ignore_errors=True) + + @pytest.mark.libreoffice + def it_converts_a_document_with_comments_to_pdf(self, tmp_docx_path: str): + if not is_libreoffice_available(): + pytest.skip("LibreOffice not available") + + doc = Document() + doc.add_paragraph("Document with comments.") + run = doc.paragraphs[0].runs[0] + doc.add_comment(run, text="Comment for LO test", author="Author") + + doc.save(tmp_docx_path) + pdf_path, outdir = validate_with_libreoffice(tmp_docx_path) + try: + assert os.path.exists(pdf_path), "PDF should have been created" + assert os.path.getsize(pdf_path) > 0, "PDF should not be empty" + finally: + shutil.rmtree(outdir, ignore_errors=True) diff --git a/tests/test_table.py b/tests/test_table.py index 479d670c6..87308579d 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -12,15 +12,28 @@ from docx.enum.style import WD_STYLE_TYPE from docx.enum.table import ( WD_ALIGN_VERTICAL, + WD_BORDER_STYLE, WD_ROW_HEIGHT, + WD_SHADING_PATTERN, WD_TABLE_ALIGNMENT, WD_TABLE_DIRECTION, ) from docx.oxml.parser import parse_xml from docx.oxml.table import CT_Row, CT_Tbl, CT_TblGridCol, CT_Tc from docx.parts.document import DocumentPart -from docx.shared import Emu, Inches, Length -from docx.table import Table, _Cell, _Column, _Columns, _Row, _Rows +from docx.shared import Emu, Inches, Length, Pt, RGBColor +from docx.table import ( + BorderElement, + CellBorders, + CellShading, + Table, + TableBorders, + _Cell, + _Column, + _Columns, + _Row, + _Rows, +) from docx.text.paragraph import Paragraph from .unitutil.cxml import element, xml @@ -43,6 +56,32 @@ def it_can_add_a_row(self, document_: Mock): assert row._tr is table._tbl.tr_lst[-1] assert row._parent is table + @pytest.mark.parametrize( + ("body_cxml", "tbl_idx", "expected_cxml"), + [ + # --- table removed from body with paragraph sibling --- + ("w:body/(w:tbl/w:tblPr,w:p)", 0, "w:body/w:p"), + # --- table removed leaving another table --- + ("w:body/(w:tbl/w:tblPr,w:tbl/w:tblPr)", 0, "w:body/w:tbl/w:tblPr"), + # --- second table removed --- + ("w:body/(w:p,w:tbl/w:tblPr,w:p)", 0, "w:body/(w:p,w:p)"), + ], + ) + def it_can_delete_itself( + self, + body_cxml: str, + tbl_idx: int, + expected_cxml: str, + document_: Mock, + ): + body = element(body_cxml) + tbl = body.tbl_lst[tbl_idx] + table = Table(tbl, document_) + + table.delete() + + assert body.xml == xml(expected_cxml) + def it_can_add_a_column(self, document_: Mock): snippets = snippet_seq("add-row-col") tbl = cast(CT_Tbl, parse_xml(snippets[0])) @@ -91,7 +130,8 @@ def it_provides_access_to_the_cells_in_a_row( _cells_.return_value = [0, 1, 2, 3, 4, 5, 6, 7, 8] _column_count_.return_value = 3 - row_cells = table.row_cells(1) + with pytest.warns(DeprecationWarning, match="row_cells"): + row_cells = table.row_cells(1) assert row_cells == [3, 4, 5] @@ -520,6 +560,11 @@ def it_can_merge_itself_with_other_cells( assert merged_cell._tc is merged_tc_ assert merged_cell._parent is cell._parent + def it_provides_access_to_its_shading(self, parent_: Mock): + cell = _Cell(cast(CT_Tc, element("w:tc")), parent_) + shading = cell.shading + assert isinstance(shading, CellShading) + # fixtures ------------------------------------------------------- @pytest.fixture @@ -539,6 +584,258 @@ def tc_2_(self, request: FixtureRequest): return instance_mock(request, CT_Tc) +class DescribeCellShading: + """Unit-test suite for `docx.table.CellShading` objects.""" + + @pytest.mark.parametrize( + ("tc_cxml", "expected_color"), + [ + ("w:tc", None), + ("w:tc/w:tcPr", None), + ("w:tc/w:tcPr/w:shd{w:fill=D9E2F3}", RGBColor(0xD9, 0xE2, 0xF3)), + ("w:tc/w:tcPr/w:shd{w:val=clear}", None), + ], + ) + def it_can_get_the_fill_color( + self, tc_cxml: str, expected_color: RGBColor | None + ): + tc = cast(CT_Tc, element(tc_cxml)) + shading = CellShading(tc) + assert shading.fill_color == expected_color + + @pytest.mark.parametrize( + ("tc_cxml", "new_color", "expected_cxml"), + [ + ( + "w:tc", + RGBColor(0xD9, 0xE2, 0xF3), + "w:tc/w:tcPr/w:shd{w:val=clear,w:fill=D9E2F3}", + ), + ( + "w:tc/w:tcPr/w:shd{w:fill=FF0000}", + RGBColor(0x00, 0x00, 0xFF), + "w:tc/w:tcPr/w:shd{w:val=clear,w:fill=0000FF}", + ), + ( + "w:tc/w:tcPr/w:shd{w:val=clear,w:fill=D9E2F3}", + None, + "w:tc/w:tcPr/w:shd{w:val=clear}", + ), + ("w:tc", None, "w:tc"), + ], + ) + def it_can_set_the_fill_color( + self, tc_cxml: str, new_color: RGBColor | None, expected_cxml: str + ): + tc = cast(CT_Tc, element(tc_cxml)) + shading = CellShading(tc) + shading.fill_color = new_color + assert tc.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("tc_cxml", "expected_pattern"), + [ + ("w:tc", None), + ("w:tc/w:tcPr", None), + ("w:tc/w:tcPr/w:shd{w:val=clear}", WD_SHADING_PATTERN.CLEAR), + ("w:tc/w:tcPr/w:shd{w:val=solid}", WD_SHADING_PATTERN.SOLID), + ], + ) + def it_can_get_the_pattern( + self, tc_cxml: str, expected_pattern: WD_SHADING_PATTERN | None + ): + tc = cast(CT_Tc, element(tc_cxml)) + shading = CellShading(tc) + assert shading.pattern == expected_pattern + + @pytest.mark.parametrize( + ("tc_cxml", "new_pattern", "expected_cxml"), + [ + ( + "w:tc", + WD_SHADING_PATTERN.CLEAR, + "w:tc/w:tcPr/w:shd{w:val=clear}", + ), + ( + "w:tc/w:tcPr/w:shd{w:val=clear}", + WD_SHADING_PATTERN.SOLID, + "w:tc/w:tcPr/w:shd{w:val=solid}", + ), + ( + "w:tc/w:tcPr/w:shd{w:val=clear,w:fill=D9E2F3}", + None, + "w:tc/w:tcPr/w:shd{w:fill=D9E2F3}", + ), + ("w:tc", None, "w:tc"), + ], + ) + def it_can_set_the_pattern( + self, + tc_cxml: str, + new_pattern: WD_SHADING_PATTERN | None, + expected_cxml: str, + ): + tc = cast(CT_Tc, element(tc_cxml)) + shading = CellShading(tc) + shading.pattern = new_pattern + assert tc.xml == xml(expected_cxml) + + +class DescribeTableBorders: + """Unit-test suite for `docx.table.TableBorders` objects.""" + + def it_provides_access_to_table_borders(self, document_: Mock): + tbl = cast(CT_Tbl, element("w:tbl/w:tblPr")) + table = Table(tbl, document_) + borders = table.borders + assert isinstance(borders, TableBorders) + + def it_can_get_border_properties_when_no_borders_exist(self, document_: Mock): + tbl = cast(CT_Tbl, element("w:tbl/w:tblPr")) + table = Table(tbl, document_) + borders = table.borders + assert borders.top.style is None + assert borders.bottom.style is None + assert borders.left.style is None + assert borders.right.style is None + assert borders.inside_h.style is None + assert borders.inside_v.style is None + + def it_can_set_a_table_border(self, document_: Mock): + tbl = cast(CT_Tbl, element("w:tbl/w:tblPr")) + table = Table(tbl, document_) + borders = table.borders + borders.top.style = WD_BORDER_STYLE.SINGLE + borders.top.width = Pt(1) + borders.top.color = RGBColor(0, 0, 0) + assert borders.top.style == WD_BORDER_STYLE.SINGLE + assert borders.top.color == RGBColor(0, 0, 0) + + def it_can_use_set_borders_convenience(self, document_: Mock): + tbl = cast(CT_Tbl, element("w:tbl/w:tblPr")) + table = Table(tbl, document_) + table.set_borders(top=True, bottom=True, inside_h=True) + borders = table.borders + assert borders.top.style == WD_BORDER_STYLE.SINGLE + assert borders.bottom.style == WD_BORDER_STYLE.SINGLE + assert borders.inside_h.style == WD_BORDER_STYLE.SINGLE + assert borders.left.style == WD_BORDER_STYLE.NONE + assert borders.right.style == WD_BORDER_STYLE.NONE + assert borders.inside_v.style == WD_BORDER_STYLE.NONE + + def it_can_set_borders_with_custom_style(self, document_: Mock): + tbl = cast(CT_Tbl, element("w:tbl/w:tblPr")) + table = Table(tbl, document_) + table.set_borders( + top=True, + bottom=True, + style=WD_BORDER_STYLE.DOUBLE, + width=Pt(2), + color=RGBColor(0xFF, 0, 0), + ) + borders = table.borders + assert borders.top.style == WD_BORDER_STYLE.DOUBLE + assert borders.top.color == RGBColor(0xFF, 0, 0) + assert borders.bottom.style == WD_BORDER_STYLE.DOUBLE + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def document_(self, request: FixtureRequest): + return instance_mock(request, Document) + + +class DescribeCellBorders: + """Unit-test suite for `docx.table.CellBorders` objects.""" + + def it_provides_access_to_cell_borders(self, parent_: Mock): + tc = cast(CT_Tc, element("w:tc")) + cell = _Cell(tc, parent_) + borders = cell.borders + assert isinstance(borders, CellBorders) + + def it_can_get_border_properties_when_no_borders_exist(self, parent_: Mock): + tc = cast(CT_Tc, element("w:tc")) + cell = _Cell(tc, parent_) + borders = cell.borders + assert borders.top.style is None + assert borders.bottom.style is None + assert borders.left.style is None + assert borders.right.style is None + + def it_can_set_a_cell_border(self, parent_: Mock): + tc = cast(CT_Tc, element("w:tc")) + cell = _Cell(tc, parent_) + borders = cell.borders + borders.top.style = WD_BORDER_STYLE.SINGLE + borders.top.width = Pt(0.5) + borders.top.color = RGBColor(0, 0, 0) + # re-read to verify + borders2 = cell.borders + assert borders2.top.style == WD_BORDER_STYLE.SINGLE + assert borders2.top.color == RGBColor(0, 0, 0) + + def it_can_clear_a_cell_border(self, parent_: Mock): + tc = cast(CT_Tc, element("w:tc")) + cell = _Cell(tc, parent_) + borders = cell.borders + borders.top.style = WD_BORDER_STYLE.SINGLE + borders.top.width = Pt(1) + # now clear + borders2 = cell.borders + borders2.top.style = None + borders2.top.width = None + borders3 = cell.borders + assert borders3.top.style is None + assert borders3.top.width is None + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def parent_(self, request: FixtureRequest): + return instance_mock(request, Table) + + +class DescribeBorderElement: + """Unit-test suite for `docx.table.BorderElement` objects.""" + + def it_can_get_the_style(self): + tbl = cast(CT_Tbl, element("w:tbl/w:tblPr/w:tblBorders/w:top{w:val=single}")) + tblBorders = tbl.tblPr.tblBorders + border_el = BorderElement(tblBorders.top, tblBorders.get_or_add_top) + assert border_el.style == WD_BORDER_STYLE.SINGLE + + def it_can_get_the_width(self): + tbl = cast(CT_Tbl, element("w:tbl/w:tblPr/w:tblBorders/w:top{w:val=single,w:sz=8}")) + tblBorders = tbl.tblPr.tblBorders + border_el = BorderElement(tblBorders.top, tblBorders.get_or_add_top) + # sz=8 means 8 eighths of a point = 1 point + assert border_el.width == Pt(1) + + def it_can_get_the_color(self): + tbl = cast( + CT_Tbl, element("w:tbl/w:tblPr/w:tblBorders/w:top{w:val=single,w:color=FF0000}") + ) + tblBorders = tbl.tblPr.tblBorders + border_el = BorderElement(tblBorders.top, tblBorders.get_or_add_top) + assert border_el.color == RGBColor(0xFF, 0, 0) + + def it_can_get_the_space(self): + tbl = cast( + CT_Tbl, element("w:tbl/w:tblPr/w:tblBorders/w:top{w:val=single,w:space=4}") + ) + tblBorders = tbl.tblPr.tblBorders + border_el = BorderElement(tblBorders.top, tblBorders.get_or_add_top) + assert border_el.space == 4 + + def it_returns_none_when_no_border_element(self): + border_el = BorderElement(None, lambda: None) # type: ignore + assert border_el.style is None + assert border_el.width is None + assert border_el.color is None + assert border_el.space is None + + class Describe_Column: """Unit-test suite for `docx.table._Cell` objects.""" @@ -658,6 +955,64 @@ def table_(self, request: FixtureRequest): class Describe_Row: """Unit-test suite for `docx.table._Row` objects.""" + @pytest.mark.parametrize( + ("tr_cxml", "expected_value"), + [ + ("w:tr", True), + ("w:tr/w:trPr", True), + ("w:tr/w:trPr/w:cantSplit", False), + ("w:tr/w:trPr/w:cantSplit{w:val=false}", True), + ], + ) + def it_knows_whether_it_allows_break_across_pages( + self, tr_cxml: str, expected_value: bool, parent_: Mock + ): + row = _Row(cast(CT_Row, element(tr_cxml)), parent_) + assert row.allow_break_across_pages is expected_value + + @pytest.mark.parametrize( + ("tr_cxml", "new_value", "expected_cxml"), + [ + ("w:tr", False, "w:tr/w:trPr/w:cantSplit"), + ("w:tr/w:trPr/w:cantSplit", True, "w:tr/w:trPr"), + ], + ) + def it_can_change_whether_it_allows_break_across_pages( + self, tr_cxml: str, new_value: bool, expected_cxml: str, parent_: Mock + ): + row = _Row(cast(CT_Row, element(tr_cxml)), parent_) + row.allow_break_across_pages = new_value + assert row._tr.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("tr_cxml", "expected_value"), + [ + ("w:tr", False), + ("w:tr/w:trPr", False), + ("w:tr/w:trPr/w:tblHeader", True), + ("w:tr/w:trPr/w:tblHeader{w:val=false}", False), + ], + ) + def it_knows_whether_it_is_a_header_row( + self, tr_cxml: str, expected_value: bool, parent_: Mock + ): + row = _Row(cast(CT_Row, element(tr_cxml)), parent_) + assert row.is_header is expected_value + + @pytest.mark.parametrize( + ("tr_cxml", "new_value", "expected_cxml"), + [ + ("w:tr", True, "w:tr/w:trPr/w:tblHeader"), + ("w:tr/w:trPr/w:tblHeader", False, "w:tr/w:trPr"), + ], + ) + def it_can_change_whether_it_is_a_header_row( + self, tr_cxml: str, new_value: bool, expected_cxml: str, parent_: Mock + ): + row = _Row(cast(CT_Row, element(tr_cxml)), parent_) + row.is_header = new_value + assert row._tr.xml == xml(expected_cxml) + @pytest.mark.parametrize( ("tr_cxml", "expected_value"), [ diff --git a/tests/test_tracked_changes.py b/tests/test_tracked_changes.py new file mode 100644 index 000000000..f18106d23 --- /dev/null +++ b/tests/test_tracked_changes.py @@ -0,0 +1,56 @@ +# pyright: reportPrivateUsage=false + +"""Unit-test suite for `docx.tracked_changes` module.""" + +from __future__ import annotations + +import datetime as dt +from typing import cast + +import pytest + +from docx.oxml.tracked_changes import CT_Del, CT_Ins +from docx.tracked_changes import TrackedChange + +from .unitutil.cxml import element + + +class DescribeTrackedChange: + """Unit-test suite for `docx.tracked_changes.TrackedChange`.""" + + def it_reports_insertion_type_for_w_ins(self): + ins = cast(CT_Ins, element("w:ins{w:id=1,w:author=Alice}")) + tc = TrackedChange(ins) + assert tc.type == "insertion" + + def it_reports_deletion_type_for_w_del(self): + del_elm = cast(CT_Del, element("w:del{w:id=2,w:author=Bob}")) + tc = TrackedChange(del_elm) + assert tc.type == "deletion" + + def it_knows_its_author(self): + ins = cast(CT_Ins, element("w:ins{w:id=1,w:author=Alice}")) + tc = TrackedChange(ins) + assert tc.author == "Alice" + + def it_knows_its_date(self): + ins = cast( + CT_Ins, element("w:ins{w:id=1,w:author=Alice,w:date=2023-10-01T12:00:00Z}") + ) + tc = TrackedChange(ins) + assert tc.date == dt.datetime(2023, 10, 1, 12, 0, 0, tzinfo=dt.timezone.utc) + + def it_returns_None_for_date_when_absent(self): + ins = cast(CT_Ins, element("w:ins{w:id=1,w:author=Alice}")) + tc = TrackedChange(ins) + assert tc.date is None + + def it_knows_its_text_for_an_insertion(self): + ins = cast(CT_Ins, element('w:ins{w:id=1,w:author=A}/w:r/w:t"inserted text"')) + tc = TrackedChange(ins) + assert tc.text == "inserted text" + + def it_knows_its_text_for_a_deletion(self): + del_elm = cast(CT_Del, element('w:del{w:id=2,w:author=B}/w:r/w:delText"deleted text"')) + tc = TrackedChange(del_elm) + assert tc.text == "deleted text" diff --git a/tests/text/test_borders.py b/tests/text/test_borders.py new file mode 100644 index 000000000..7d5158620 --- /dev/null +++ b/tests/text/test_borders.py @@ -0,0 +1,191 @@ +"""Test suite for docx.text.parfmt module — paragraph borders.""" + +import pytest + +from docx.enum.text import WD_BORDER_STYLE +from docx.shared import Pt, RGBColor +from docx.text.parfmt import Border, ParagraphBorders, ParagraphFormat + +from ..unitutil.cxml import element, xml + + +class DescribeParagraphFormat: + def it_provides_access_to_its_borders(self): + p = element("w:p") + paragraph_format = ParagraphFormat(p) + borders = paragraph_format.borders + assert isinstance(borders, ParagraphBorders) + + +class DescribeParagraphBorders: + def it_provides_access_to_each_border_side(self): + p = element("w:p") + borders = ParagraphBorders(p) + for side in ("top", "bottom", "left", "right", "between", "bar"): + border = getattr(borders, side) + assert isinstance(border, Border) + + +class DescribeBorder: + def it_returns_None_for_style_when_no_border_exists(self): + p = element("w:p") + border = Border(p, "bottom") + assert border.style is None + + def it_returns_None_for_style_when_pPr_exists_but_no_pBdr(self): + p = element("w:p/w:pPr") + border = Border(p, "bottom") + assert border.style is None + + def it_returns_None_for_style_when_pBdr_exists_but_no_side(self): + p = element("w:p/w:pPr/w:pBdr") + border = Border(p, "bottom") + assert border.style is None + + def it_can_get_the_border_style(self): + p = element("w:p/w:pPr/w:pBdr/w:bottom{w:val=single}") + border = Border(p, "bottom") + assert border.style == WD_BORDER_STYLE.SINGLE + + def it_can_set_the_border_style(self): + p = element("w:p") + border = Border(p, "bottom") + border.style = WD_BORDER_STYLE.DOUBLE + assert border.style == WD_BORDER_STYLE.DOUBLE + + def it_can_clear_the_border_style(self): + p = element("w:p/w:pPr/w:pBdr/w:bottom{w:val=single}") + border = Border(p, "bottom") + border.style = None + assert border.style is None + + def it_returns_None_for_width_when_no_border_exists(self): + p = element("w:p") + border = Border(p, "top") + assert border.width is None + + def it_can_get_the_border_width(self): + p = element("w:p/w:pPr/w:pBdr/w:bottom{w:val=single,w:sz=8}") + border = Border(p, "bottom") + assert border.width == Pt(1) + + def it_can_set_the_border_width(self): + p = element("w:p") + border = Border(p, "bottom") + border.width = Pt(2) + assert border.width == Pt(2) + + def it_returns_None_for_color_when_no_border_exists(self): + p = element("w:p") + border = Border(p, "bottom") + assert border.color is None + + def it_can_get_the_border_color(self): + p = element("w:p/w:pPr/w:pBdr/w:bottom{w:val=single,w:color=FF0000}") + border = Border(p, "bottom") + assert border.color == RGBColor(0xFF, 0x00, 0x00) + + def it_can_set_the_border_color(self): + p = element("w:p") + border = Border(p, "bottom") + border.color = RGBColor(0x00, 0x00, 0xFF) + assert border.color == RGBColor(0x00, 0x00, 0xFF) + + def it_returns_None_for_space_when_no_border_exists(self): + p = element("w:p") + border = Border(p, "bottom") + assert border.space is None + + def it_can_get_the_border_space(self): + p = element("w:p/w:pPr/w:pBdr/w:bottom{w:val=single,w:space=4}") + border = Border(p, "bottom") + assert border.space == Pt(4) + + def it_can_set_the_border_space(self): + p = element("w:p") + border = Border(p, "bottom") + border.space = Pt(8) + assert border.space == Pt(8) + + def it_does_not_create_an_element_when_setting_width_to_None_on_a_nonexistent_border(self): + p = element("w:p") + border = Border(p, "bottom") + border.width = None + assert p.xml == xml("w:p") + + def it_does_not_create_an_element_when_setting_space_to_None_on_a_nonexistent_border(self): + p = element("w:p") + border = Border(p, "bottom") + border.space = None + assert p.xml == xml("w:p") + + def it_does_not_create_an_element_when_setting_color_to_None_on_a_nonexistent_border(self): + p = element("w:p") + border = Border(p, "bottom") + border.color = None + assert p.xml == xml("w:p") + + def it_returns_None_for_auto_color(self): + p = element("w:p/w:pPr/w:pBdr/w:bottom{w:val=single,w:color=auto}") + border = Border(p, "bottom") + assert border.color is None + + def it_clears_width_on_an_existing_border_when_set_to_None(self): + p = element("w:p/w:pPr/w:pBdr/w:bottom{w:val=single,w:sz=8}") + border = Border(p, "bottom") + border.width = None + assert border.width is None + assert border.style == WD_BORDER_STYLE.SINGLE + + def it_clears_color_on_an_existing_border_when_set_to_None(self): + p = element("w:p/w:pPr/w:pBdr/w:bottom{w:val=single,w:color=FF0000}") + border = Border(p, "bottom") + border.color = None + assert border.color is None + assert border.style == WD_BORDER_STYLE.SINGLE + + def it_clears_space_on_an_existing_border_when_set_to_None(self): + p = element("w:p/w:pPr/w:pBdr/w:bottom{w:val=single,w:space=4}") + border = Border(p, "bottom") + border.space = None + assert border.space is None + assert border.style == WD_BORDER_STYLE.SINGLE + + def it_works_for_all_sides(self): + for side in ("top", "bottom", "left", "right", "between", "bar"): + p = element("w:p") + border = Border(p, side) + border.style = WD_BORDER_STYLE.SINGLE + border.width = Pt(1) + assert border.style == WD_BORDER_STYLE.SINGLE + assert border.width == Pt(1) + + def it_can_set_all_border_properties_at_once(self): + p = element("w:p") + border = Border(p, "bottom") + border.style = WD_BORDER_STYLE.SINGLE + border.width = Pt(1) + border.color = RGBColor(0x4F, 0x81, 0xBD) + border.space = Pt(4) + expected_xml = xml( + "w:p/w:pPr/w:pBdr/w:bottom{w:val=single,w:sz=8,w:space=4,w:color=4F81BD}" + ) + assert p.xml == expected_xml + + +class DescribeCT_PBdr: + def it_can_add_border_elements(self): + pBdr = element("w:pBdr") + bottom = pBdr.get_or_add_bottom() + assert bottom is not None + bottom.val = WD_BORDER_STYLE.SINGLE + assert pBdr.bottom.val == WD_BORDER_STYLE.SINGLE + + def it_preserves_element_order(self): + pBdr = element("w:pBdr") + pBdr.get_or_add_bottom() + pBdr.get_or_add_top() + # top should come before bottom in XML + children = list(pBdr) + assert children[0].tag.endswith("}top") + assert children[1].tag.endswith("}bottom") diff --git a/tests/text/test_font.py b/tests/text/test_font.py index 471c5451b..c9b314931 100644 --- a/tests/text/test_font.py +++ b/tests/text/test_font.py @@ -31,6 +31,72 @@ def it_provides_access_to_its_color_object(self, ColorFormat_: Mock, color_: Moc ColorFormat_.assert_called_once_with(font.element) assert color is color_ + @pytest.mark.parametrize( + ("r_cxml", "expected_value"), + [ + ("w:r", None), + ("w:r/w:rPr", None), + ("w:r/w:rPr/w:spacing{w:val=40}", Pt(2)), + ], + ) + def it_knows_its_character_spacing(self, r_cxml: str, expected_value: Length | None): + r = cast(CT_R, element(r_cxml)) + font = Font(r) + assert font.character_spacing == expected_value + + @pytest.mark.parametrize( + ("r_cxml", "value", "expected_r_cxml"), + [ + ("w:r", Pt(2), "w:r/w:rPr/w:spacing{w:val=40}"), + ("w:r/w:rPr", Pt(1), "w:r/w:rPr/w:spacing{w:val=20}"), + ("w:r/w:rPr/w:spacing{w:val=40}", Pt(3), "w:r/w:rPr/w:spacing{w:val=60}"), + ("w:r/w:rPr/w:spacing{w:val=40}", None, "w:r/w:rPr"), + ], + ) + def it_can_change_its_character_spacing( + self, r_cxml: str, value: Length | None, expected_r_cxml: str + ): + r = cast(CT_R, element(r_cxml)) + font = Font(r) + expected_xml = xml(expected_r_cxml) + + font.character_spacing = value + + assert font._element.xml == expected_xml + + @pytest.mark.parametrize( + ("r_cxml", "expected_value"), + [ + ("w:r", None), + ("w:r/w:rPr", None), + ("w:r/w:rPr/w:kern{w:val=28}", Pt(14)), + ], + ) + def it_knows_its_kerning(self, r_cxml: str, expected_value: Length | None): + r = cast(CT_R, element(r_cxml)) + font = Font(r) + assert font.kerning == expected_value + + @pytest.mark.parametrize( + ("r_cxml", "value", "expected_r_cxml"), + [ + ("w:r", Pt(14), "w:r/w:rPr/w:kern{w:val=28}"), + ("w:r/w:rPr", Pt(16), "w:r/w:rPr/w:kern{w:val=32}"), + ("w:r/w:rPr/w:kern{w:val=28}", Pt(16), "w:r/w:rPr/w:kern{w:val=32}"), + ("w:r/w:rPr/w:kern{w:val=28}", None, "w:r/w:rPr"), + ], + ) + def it_can_change_its_kerning( + self, r_cxml: str, value: Length | None, expected_r_cxml: str + ): + r = cast(CT_R, element(r_cxml)) + font = Font(r) + expected_xml = xml(expected_r_cxml) + + font.kerning = value + + assert font._element.xml == expected_xml + @pytest.mark.parametrize( ("r_cxml", "expected_value"), [ @@ -71,6 +137,100 @@ def it_can_change_its_typeface_name(self, r_cxml: str, value: str, expected_r_cx assert font._element.xml == expected_xml + @pytest.mark.parametrize( + ("r_cxml", "expected_value"), + [ + ("w:r", None), + ("w:r/w:rPr", None), + ("w:r/w:rPr/w:rFonts", None), + ("w:r/w:rPr/w:rFonts{w:cs=Courier New}", "Courier New"), + ], + ) + def it_knows_its_complex_script_typeface_name( + self, r_cxml: str, expected_value: str | None + ): + r = cast(CT_R, element(r_cxml)) + font = Font(r) + assert font.name_cs == expected_value + + @pytest.mark.parametrize( + ("r_cxml", "value", "expected_r_cxml"), + [ + ("w:r", "Foo", "w:r/w:rPr/w:rFonts{w:cs=Foo}"), + ("w:r/w:rPr", "Foo", "w:r/w:rPr/w:rFonts{w:cs=Foo}"), + ( + "w:r/w:rPr/w:rFonts{w:cs=Foo}", + "Bar", + "w:r/w:rPr/w:rFonts{w:cs=Bar}", + ), + ( + "w:r/w:rPr/w:rFonts{w:ascii=Arial,w:cs=Foo}", + "Bar", + "w:r/w:rPr/w:rFonts{w:ascii=Arial,w:cs=Bar}", + ), + ], + ) + def it_can_change_its_complex_script_typeface_name( + self, r_cxml: str, value: str, expected_r_cxml: str + ): + r = cast(CT_R, element(r_cxml)) + font = Font(r) + expected_xml = xml(expected_r_cxml) + + font.name_cs = value + + assert font._element.xml == expected_xml + + @pytest.mark.parametrize( + ("r_cxml", "expected_value"), + [ + ("w:r", None), + ("w:r/w:rPr", None), + ("w:r/w:rPr/w:rFonts", None), + ("w:r/w:rPr/w:rFonts{w:eastAsia=SimSun}", "SimSun"), + ], + ) + def it_knows_its_far_east_typeface_name(self, r_cxml: str, expected_value: str | None): + r = cast(CT_R, element(r_cxml)) + font = Font(r) + assert font.name_far_east == expected_value + + @pytest.mark.parametrize( + ("r_cxml", "value", "expected_r_cxml"), + [ + ("w:r", "SimSun", "w:r/w:rPr/w:rFonts{w:eastAsia=SimSun}"), + ("w:r/w:rPr", "SimSun", "w:r/w:rPr/w:rFonts{w:eastAsia=SimSun}"), + ( + "w:r/w:rPr/w:rFonts{w:eastAsia=SimSun}", + "MS Mincho", + "w:r/w:rPr/w:rFonts{w:eastAsia=MS Mincho}", + ), + ( + "w:r/w:rPr/w:rFonts{w:ascii=Arial,w:eastAsia=SimSun}", + "MS Mincho", + "w:r/w:rPr/w:rFonts{w:ascii=Arial,w:eastAsia=MS Mincho}", + ), + ], + ) + def it_can_change_its_far_east_typeface_name( + self, r_cxml: str, value: str, expected_r_cxml: str + ): + r = cast(CT_R, element(r_cxml)) + font = Font(r) + expected_xml = xml(expected_r_cxml) + + font.name_far_east = value + + assert font._element.xml == expected_xml + + def it_provides_name_east_asia_as_alias_for_name_far_east(self): + r = cast(CT_R, element("w:r/w:rPr/w:rFonts{w:eastAsia=SimSun}")) + font = Font(r) + assert font.name_east_asia == "SimSun" + + font.name_east_asia = "MS Mincho" + assert font.name_far_east == "MS Mincho" + @pytest.mark.parametrize( ("r_cxml", "expected_value"), [ diff --git a/tests/text/test_paragraph.py b/tests/text/test_paragraph.py index 0329b1dd3..98aa90294 100644 --- a/tests/text/test_paragraph.py +++ b/tests/text/test_paragraph.py @@ -5,11 +5,16 @@ import pytest from docx import types as t +from docx.enum.section import WD_SECTION_START from docx.enum.style import WD_STYLE_TYPE from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.drawing import Drawing from docx.oxml.text.paragraph import CT_P from docx.oxml.text.run import CT_R from docx.parts.document import DocumentPart +from docx.parts.story import StoryPart +from docx.section import Section +from docx.text.hyperlink import Hyperlink from docx.text.paragraph import Paragraph from docx.text.parfmt import ParagraphFormat from docx.text.run import Run @@ -21,6 +26,267 @@ class DescribeParagraph: """Unit-test suite for `docx.text.run.Paragraph`.""" + def it_can_add_an_external_hyperlink(self, request: pytest.FixtureRequest): + story_part_ = instance_mock(request, StoryPart) + story_part_.relate_to.return_value = "rId7" + story_part_.get_style_id.return_value = "Hyperlink" + + class FakeParent: + @property + def part(self): + return story_part_ + + p = cast(CT_P, element("w:p")) + paragraph = Paragraph(p, FakeParent()) + + hyperlink = paragraph.add_hyperlink(url="https://example.com", text="Click here") + + assert isinstance(hyperlink, Hyperlink) + assert hyperlink.text == "Click here" + assert len(hyperlink.runs) == 1 + assert len(paragraph.hyperlinks) == 1 + # -- the hyperlink element has the correct rId -- + assert hyperlink._hyperlink.rId == "rId7" + story_part_.relate_to.assert_called_once() + + def it_can_add_an_internal_hyperlink(self, request: pytest.FixtureRequest): + story_part_ = instance_mock(request, StoryPart) + story_part_.get_style_id.return_value = "Hyperlink" + + class FakeParent: + @property + def part(self): + return story_part_ + + p = cast(CT_P, element("w:p")) + paragraph = Paragraph(p, FakeParent()) + + hyperlink = paragraph.add_hyperlink(anchor="bookmark1", text="Go to section") + + assert isinstance(hyperlink, Hyperlink) + assert hyperlink.text == "Go to section" + assert hyperlink.fragment == "bookmark1" + assert hyperlink._hyperlink.rId is None + + def it_defaults_text_to_url_when_not_provided(self, request: pytest.FixtureRequest): + story_part_ = instance_mock(request, StoryPart) + story_part_.relate_to.return_value = "rId7" + story_part_.get_style_id.return_value = "Hyperlink" + + class FakeParent: + @property + def part(self): + return story_part_ + + p = cast(CT_P, element("w:p")) + paragraph = Paragraph(p, FakeParent()) + + hyperlink = paragraph.add_hyperlink(url="https://example.com") + + assert hyperlink.text == "https://example.com" + + def it_raises_when_neither_url_nor_anchor_is_provided( + self, fake_parent: t.ProvidesStoryPart + ): + p = cast(CT_P, element("w:p")) + paragraph = Paragraph(p, fake_parent) + + with pytest.raises(ValueError, match="Either url or anchor must be provided"): + paragraph.add_hyperlink() + + def it_raises_when_both_url_and_anchor_are_provided( + self, fake_parent: t.ProvidesStoryPart + ): + p = cast(CT_P, element("w:p")) + paragraph = Paragraph(p, fake_parent) + + with pytest.raises(ValueError, match="Only one of url or anchor"): + paragraph.add_hyperlink(url="https://example.com", anchor="bookmark1") + + def it_can_add_a_hyperlink_without_style(self, request: pytest.FixtureRequest): + story_part_ = instance_mock(request, StoryPart) + story_part_.relate_to.return_value = "rId7" + + class FakeParent: + @property + def part(self): + return story_part_ + + p = cast(CT_P, element("w:p")) + paragraph = Paragraph(p, FakeParent()) + + hyperlink = paragraph.add_hyperlink( + url="https://example.com", text="Click", style=None + ) + + assert isinstance(hyperlink, Hyperlink) + assert hyperlink.text == "Click" + # -- no rPr/rStyle should be present -- + runs = hyperlink.runs + assert len(runs) == 1 + assert runs[0]._r.rPr is None + + def it_can_add_a_page_break(self, fake_parent: t.ProvidesStoryPart): + p = cast(CT_P, element("w:p")) + paragraph = Paragraph(p, fake_parent) + + result = paragraph.add_page_break() + + assert result is paragraph + assert paragraph.has_page_break is True + assert len(paragraph.runs) == 1 + assert paragraph._p.xml == xml("w:p/w:r/w:br{w:type=page}") + + @pytest.mark.parametrize( + ("p_cxml", "expected_value"), + [ + ("w:p", False), + ("w:p/w:r", False), + ('w:p/w:r/w:t"foobar"', False), + ("w:p/w:r/w:br{w:type=page}", True), + ("w:p/w:r/w:br", False), + ('w:p/(w:r/w:t"abc",w:r/w:br{w:type=page})', True), + ], + ) + def it_knows_whether_it_has_a_page_break( + self, p_cxml: str, expected_value: bool, fake_parent: t.ProvidesStoryPart + ): + p = cast(CT_P, element(p_cxml)) + paragraph = Paragraph(p, fake_parent) + + assert paragraph.has_page_break == expected_value + + @pytest.mark.parametrize( + ("p_cxml", "expected_cxml"), + [ + # --- no page breaks: no-op --- + ("w:p", "w:p"), + ("w:p/w:r", "w:p/w:r"), + # --- run with only page break is removed entirely --- + ("w:p/w:r/w:br{w:type=page}", "w:p"), + # --- run with text and page break: only br removed --- + ('w:p/w:r/(w:t"abc",w:br{w:type=page})', 'w:p/w:r/w:t"abc"'), + # --- multiple page breaks --- + ( + 'w:p/(w:r/w:br{w:type=page},w:r/w:t"abc",w:r/w:br{w:type=page})', + 'w:p/w:r/w:t"abc"', + ), + # --- line break (not page) is preserved --- + ("w:p/w:r/w:br", "w:p/w:r/w:br"), + ], + ) + def it_can_clear_page_breaks( + self, p_cxml: str, expected_cxml: str, fake_parent: t.ProvidesStoryPart + ): + p = cast(CT_P, element(p_cxml)) + paragraph = Paragraph(p, fake_parent) + + paragraph.clear_page_breaks() + + assert paragraph._p.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("body_cxml", "p_idx", "expected_cxml"), + [ + # --- paragraph is removed from body --- + ("w:body/(w:p,w:p)", 0, "w:body/w:p"), + # --- last paragraph in body can be removed --- + ("w:body/w:p", 0, "w:body"), + # --- paragraph with formatting is removed --- + ('w:body/(w:p/w:pPr/w:pStyle{w:val=Heading1},w:p/w:r/w:t"keep")', 0, + 'w:body/w:p/w:r/w:t"keep"'), + # --- middle paragraph removed --- + ("w:body/(w:p,w:p,w:p)", 1, "w:body/(w:p,w:p)"), + ], + ) + def it_can_delete_itself( + self, + body_cxml: str, + p_idx: int, + expected_cxml: str, + fake_parent: t.ProvidesStoryPart, + ): + body = element(body_cxml) + p = body[p_idx] + paragraph = Paragraph(cast(CT_P, p), fake_parent) + + paragraph.delete() + + assert body.xml == xml(expected_cxml) + + @pytest.mark.parametrize( + ("p_cxml", "expected_value"), + [ + ("w:p", False), + ("w:p/w:pPr", False), + ("w:p/w:pPr/w:sectPr", True), + ("w:p/w:pPr/w:sectPr/w:type{w:val=continuous}", True), + ], + ) + def it_knows_whether_it_has_a_section_break( + self, p_cxml: str, expected_value: bool + ): + paragraph = Paragraph(cast(CT_P, element(p_cxml)), None) + assert paragraph.has_section_break is expected_value + + @pytest.mark.parametrize( + ("p_cxml", "start_type", "expected_start_type"), + [ + ("w:p", WD_SECTION_START.NEW_PAGE, WD_SECTION_START.NEW_PAGE), + ("w:p", WD_SECTION_START.CONTINUOUS, WD_SECTION_START.CONTINUOUS), + ("w:p", WD_SECTION_START.ODD_PAGE, WD_SECTION_START.ODD_PAGE), + ("w:p", WD_SECTION_START.EVEN_PAGE, WD_SECTION_START.EVEN_PAGE), + # --- replacing existing sectPr type --- + ( + "w:p/w:pPr/w:sectPr/w:type{w:val=continuous}", + WD_SECTION_START.ODD_PAGE, + WD_SECTION_START.ODD_PAGE, + ), + ], + ) + def it_can_insert_a_section_break( + self, + p_cxml: str, + start_type: WD_SECTION_START, + expected_start_type: WD_SECTION_START, + part_prop_: DocumentPart, + ): + paragraph = Paragraph(cast(CT_P, element(p_cxml)), None) + section = paragraph.insert_section_break(start_type) + assert isinstance(section, Section) + assert section.start_type == expected_start_type + assert paragraph.has_section_break is True + + def it_inserts_a_section_break_with_default_start_type(self, part_prop_: DocumentPart): + paragraph = Paragraph(cast(CT_P, element("w:p")), None) + section = paragraph.insert_section_break() + assert isinstance(section, Section) + assert section.start_type == WD_SECTION_START.NEW_PAGE + + def it_does_not_duplicate_sectPr_on_repeated_insert(self, part_prop_: DocumentPart): + paragraph = Paragraph(cast(CT_P, element("w:p")), None) + paragraph.insert_section_break(WD_SECTION_START.CONTINUOUS) + paragraph.insert_section_break(WD_SECTION_START.ODD_PAGE) + sectPr_elements = paragraph._p.pPr.xpath("w:sectPr") + assert len(sectPr_elements) == 1 + assert paragraph.has_section_break is True + + @pytest.mark.parametrize( + ("p_cxml", "expected_has_break_after"), + [ + ("w:p/w:pPr/w:sectPr", False), + ("w:p/w:pPr/w:sectPr/w:type{w:val=continuous}", False), + ("w:p", False), + ("w:p/w:pPr", False), + ], + ) + def it_can_remove_a_section_break( + self, p_cxml: str, expected_has_break_after: bool + ): + paragraph = Paragraph(cast(CT_P, element(p_cxml)), None) + paragraph.remove_section_break() + assert paragraph.has_section_break is expected_has_break_after + @pytest.mark.parametrize( ("p_cxml", "expected_value"), [ @@ -38,6 +304,27 @@ def it_knows_whether_it_contains_a_page_break( assert paragraph.contains_page_break == expected_value + @pytest.mark.parametrize( + ("p_cxml", "count"), + [ + ("w:p", 0), + ("w:p/w:r", 0), + ("w:p/w:r/w:drawing", 1), + ("w:p/(w:r/w:drawing,w:r/w:drawing)", 2), + ("w:p/(w:r/w:drawing,w:r)", 1), + ], + ) + def it_provides_access_to_drawings_it_contains( + self, p_cxml: str, count: int, fake_parent: t.ProvidesStoryPart + ): + p = cast(CT_P, element(p_cxml)) + paragraph = Paragraph(p, fake_parent) + + drawings = paragraph.drawings + + assert len(drawings) == count + assert all(isinstance(d, Drawing) for d in drawings) + @pytest.mark.parametrize( ("p_cxml", "count"), [ @@ -149,6 +436,27 @@ def it_knows_the_text_it_contains(self, p_cxml: str, expected_value: str): paragraph = Paragraph(element(p_cxml), None) assert paragraph.text == expected_value + @pytest.mark.parametrize( + ("p_cxml", "count"), + [ + ("w:p", 0), + ('w:p/w:r/w:t"no changes"', 0), + ('w:p/w:ins{w:id=1,w:author=A}/w:r/w:t"added"', 1), + ('w:p/w:del{w:id=2,w:author=B}/w:r/w:delText"removed"', 1), + ( + 'w:p/(w:ins{w:id=1,w:author=A}/w:r/w:t"added"' + ',w:del{w:id=2,w:author=B}/w:r/w:delText"removed")', + 2, + ), + ], + ) + def it_provides_access_to_tracked_changes(self, p_cxml: str, count: int): + paragraph = Paragraph(element(p_cxml), None) + + tracked_changes = paragraph.tracked_changes + + assert len(tracked_changes) == count + def it_can_replace_the_text_it_contains(self, text_set_fixture): paragraph, text, expected_text = text_set_fixture paragraph.text = text @@ -195,6 +503,30 @@ def it_can_insert_a_paragraph_before_itself(self, insert_before_fixture): assert new_paragraph.style == style assert new_paragraph is paragraph_ + def it_updates_section_count_on_insert_and_remove(self, part_prop_: DocumentPart): + document_elm = element( + "w:document/w:body/(w:p,w:p,w:sectPr)" + ) + body = document_elm[0] + p1 = body[0] + p2 = body[1] + paragraph1 = Paragraph(cast(CT_P, p1), None) + paragraph2 = Paragraph(cast(CT_P, p2), None) + # --- starts with 1 section (the body sectPr) --- + assert len(document_elm.sectPr_lst) == 1 + # --- insert section break on paragraph1 --- + paragraph1.insert_section_break(WD_SECTION_START.CONTINUOUS) + assert len(document_elm.sectPr_lst) == 2 + # --- insert section break on paragraph2 --- + paragraph2.insert_section_break(WD_SECTION_START.ODD_PAGE) + assert len(document_elm.sectPr_lst) == 3 + # --- remove section break from paragraph1 --- + paragraph1.remove_section_break() + assert len(document_elm.sectPr_lst) == 2 + # --- remove section break from paragraph2 --- + paragraph2.remove_section_break() + assert len(document_elm.sectPr_lst) == 1 + def it_can_remove_its_content_while_preserving_formatting(self, clear_fixture): paragraph, expected_xml = clear_fixture _paragraph = paragraph.clear() diff --git a/tests/text/test_run.py b/tests/text/test_run.py index 910f445d1..23fb74f3c 100644 --- a/tests/text/test_run.py +++ b/tests/text/test_run.py @@ -78,6 +78,34 @@ def it_can_change_its_bool_prop_settings( assert run._r.xml == xml(expected_cxml) + @pytest.mark.parametrize( + ("p_cxml", "r_idx", "expected_cxml"), + [ + # --- run is removed from paragraph --- + ("w:p/(w:r,w:r)", 0, "w:p/w:r"), + # --- only run removed --- + ("w:p/w:r", 0, "w:p"), + # --- run with text removed, sibling kept --- + ('w:p/(w:r/w:t"delete",w:r/w:t"keep")', 0, 'w:p/w:r/w:t"keep"'), + # --- middle run removed --- + ("w:p/(w:r,w:r,w:r)", 1, "w:p/(w:r,w:r)"), + ], + ) + def it_can_delete_itself( + self, + p_cxml: str, + r_idx: int, + expected_cxml: str, + paragraph_: Mock, + ): + p = element(p_cxml) + r = p.r_lst[r_idx] + run = Run(cast(CT_R, r), paragraph_) + + run.delete() + + assert p.xml == xml(expected_cxml) + @pytest.mark.parametrize( ("r_cxml", "expected_value"), [ @@ -342,6 +370,44 @@ def it_can_remove_its_content_but_keep_formatting( assert run._r.xml == xml(expected_cxml) assert cleared_run is run + @pytest.mark.parametrize( + ("p_cxml", "offset", "expected_left", "expected_right"), + [ + ('w:p/w:r/w:t"foobar"', 3, "foo", "bar"), + ('w:p/w:r/(w:rPr/w:b,w:t"foobar")', 3, "foo", "bar"), + ('w:p/w:r/w:t"foobar"', 0, "", "foobar"), + ('w:p/w:r/w:t"foobar"', 6, "foobar", ""), + ], + ) + def it_can_split_at_a_character_position( + self, + p_cxml: str, + offset: int, + expected_left: str, + expected_right: str, + fake_parent: t.ProvidesStoryPart, + ): + p = cast(CT_P, element(p_cxml)) + run = Run(p.r_lst[0], fake_parent) + + left, right = run.split(offset) + + assert left is run + assert isinstance(right, Run) + assert left.text == expected_left + assert right.text == expected_right + + def it_preserves_formatting_on_split(self, fake_parent: t.ProvidesStoryPart): + p = cast(CT_P, element('w:p/w:r/(w:rPr/(w:b,w:i),w:t"foobar")')) + run = Run(p.r_lst[0], fake_parent) + + left, right = run.split(3) + + assert left.bold is True + assert right.bold is True + assert left.italic is True + assert right.italic is True + @pytest.mark.parametrize( ("r_cxml", "expected_text"), [ diff --git a/tox.ini b/tox.ini index 1f4741b6f..b2c7e0bcb 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,11 @@ envlist = py39, py310, py311, py312, py313 [testenv] -deps = -rrequirements-test.txt +deps = + behave>=1.2.6 + pyparsing>=3.2.3 + pytest>=8.4.0 + ruff>=0.11.13 commands = py.test -qx diff --git a/uv.lock b/uv.lock index 7888c5298..9527cc2f3 100644 --- a/uv.lock +++ b/uv.lock @@ -1,32 +1,32 @@ version = 1 -revision = 1 +revision = 3 requires-python = ">=3.9" [[package]] name = "alabaster" version = "0.7.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/71/a8ee96d1fd95ca04a0d2e2d9c4081dac4c2d2b12f7ddb899c8cb9bfd1532/alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2", size = 11454 } +sdist = { url = "https://files.pythonhosted.org/packages/94/71/a8ee96d1fd95ca04a0d2e2d9c4081dac4c2d2b12f7ddb899c8cb9bfd1532/alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2", size = 11454, upload-time = "2023-01-13T06:42:53.797Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/88/c7083fc61120ab661c5d0b82cb77079fc1429d3f913a456c1c82cf4658f7/alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3", size = 13857 }, + { url = "https://files.pythonhosted.org/packages/64/88/c7083fc61120ab661c5d0b82cb77079fc1429d3f913a456c1c82cf4658f7/alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3", size = 13857, upload-time = "2023-01-13T06:42:52.336Z" }, ] [[package]] name = "babel" version = "2.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852 } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 }, + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, ] [[package]] name = "backports-tarfile" version = "1.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406 } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181 }, + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, ] [[package]] @@ -37,9 +37,9 @@ dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067, upload-time = "2025-04-15T17:05:13.836Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285 }, + { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload-time = "2025-04-15T17:05:12.221Z" }, ] [[package]] @@ -51,27 +51,27 @@ dependencies = [ { name = "parse-type" }, { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c8/4b/d0a8c23b6c8985e5544ea96d27105a273ea22051317f850c2cdbf2029fe4/behave-1.2.6.tar.gz", hash = "sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86", size = 701696 } +sdist = { url = "https://files.pythonhosted.org/packages/c8/4b/d0a8c23b6c8985e5544ea96d27105a273ea22051317f850c2cdbf2029fe4/behave-1.2.6.tar.gz", hash = "sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86", size = 701696, upload-time = "2018-02-25T20:06:38.851Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/6c/ec9169548b6c4cb877aaa6773408ca08ae2a282805b958dbc163cb19822d/behave-1.2.6-py2.py3-none-any.whl", hash = "sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c", size = 136779 }, + { url = "https://files.pythonhosted.org/packages/a8/6c/ec9169548b6c4cb877aaa6773408ca08ae2a282805b958dbc163cb19822d/behave-1.2.6-py2.py3-none-any.whl", hash = "sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c", size = 136779, upload-time = "2018-02-25T20:06:34.436Z" }, ] [[package]] name = "cachetools" version = "6.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/b0/f539a1ddff36644c28a61490056e5bae43bd7386d9f9c69beae2d7e7d6d1/cachetools-6.0.0.tar.gz", hash = "sha256:f225782b84438f828328fc2ad74346522f27e5b1440f4e9fd18b20ebfd1aa2cf", size = 30160 } +sdist = { url = "https://files.pythonhosted.org/packages/c0/b0/f539a1ddff36644c28a61490056e5bae43bd7386d9f9c69beae2d7e7d6d1/cachetools-6.0.0.tar.gz", hash = "sha256:f225782b84438f828328fc2ad74346522f27e5b1440f4e9fd18b20ebfd1aa2cf", size = 30160, upload-time = "2025-05-23T20:01:13.076Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/c3/8bb087c903c95a570015ce84e0c23ae1d79f528c349cbc141b5c4e250293/cachetools-6.0.0-py3-none-any.whl", hash = "sha256:82e73ba88f7b30228b5507dce1a1f878498fc669d972aef2dde4f3a3c24f103e", size = 10964 }, + { url = "https://files.pythonhosted.org/packages/6a/c3/8bb087c903c95a570015ce84e0c23ae1d79f528c349cbc141b5c4e250293/cachetools-6.0.0-py3-none-any.whl", hash = "sha256:82e73ba88f7b30228b5507dce1a1f878498fc669d972aef2dde4f3a3c24f103e", size = 10964, upload-time = "2025-05-23T20:01:11.323Z" }, ] [[package]] name = "certifi" version = "2025.4.26" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705 } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618 }, + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, ] [[package]] @@ -81,138 +81,128 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, - { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, - { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, - { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, - { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, - { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, - { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, - { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, - { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, - { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, - { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, - { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, - { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, - { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, - { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, - { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, - { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, - { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, - { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, - { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, - { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, - { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, - { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, - { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, - { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, - { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, - { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, - { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, - { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, - { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, - { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910 }, - { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200 }, - { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565 }, - { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635 }, - { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218 }, - { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486 }, - { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911 }, - { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632 }, +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload-time = "2024-09-04T20:43:34.186Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload-time = "2024-09-04T20:43:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload-time = "2024-09-04T20:43:41.526Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload-time = "2024-09-04T20:43:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload-time = "2024-09-04T20:43:45.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload-time = "2024-09-04T20:43:46.779Z" }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910, upload-time = "2024-09-04T20:45:05.315Z" }, + { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200, upload-time = "2024-09-04T20:45:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218, upload-time = "2024-09-04T20:45:12.366Z" }, + { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486, upload-time = "2024-09-04T20:45:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911, upload-time = "2024-09-04T20:45:15.696Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632, upload-time = "2024-09-04T20:45:17.284Z" }, ] [[package]] name = "chardet" version = "5.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618 } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385 }, + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", size = 201818 }, - { url = "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", size = 144649 }, - { url = "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", size = 155045 }, - { url = "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", size = 147356 }, - { url = "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", size = 149471 }, - { url = "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", size = 151317 }, - { url = "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", size = 146368 }, - { url = "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", size = 154491 }, - { url = "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", size = 157695 }, - { url = "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", size = 154849 }, - { url = "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", size = 150091 }, - { url = "https://files.pythonhosted.org/packages/1f/79/4b8da9f712bc079c0f16b6d67b099b0b8d808c2292c937f267d816ec5ecc/charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", size = 98445 }, - { url = "https://files.pythonhosted.org/packages/7d/d7/96970afb4fb66497a40761cdf7bd4f6fca0fc7bafde3a84f836c1f57a926/charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", size = 105782 }, - { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794 }, - { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846 }, - { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350 }, - { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657 }, - { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260 }, - { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164 }, - { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571 }, - { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952 }, - { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959 }, - { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030 }, - { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015 }, - { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106 }, - { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402 }, - { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936 }, - { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790 }, - { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924 }, - { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626 }, - { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567 }, - { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957 }, - { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408 }, - { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399 }, - { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815 }, - { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537 }, - { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565 }, - { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357 }, - { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776 }, - { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622 }, - { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435 }, - { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653 }, - { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231 }, - { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243 }, - { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442 }, - { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147 }, - { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057 }, - { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454 }, - { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174 }, - { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166 }, - { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064 }, - { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641 }, - { url = "https://files.pythonhosted.org/packages/28/f8/dfb01ff6cc9af38552c69c9027501ff5a5117c4cc18dcd27cb5259fa1888/charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", size = 201671 }, - { url = "https://files.pythonhosted.org/packages/32/fb/74e26ee556a9dbfe3bd264289b67be1e6d616329403036f6507bb9f3f29c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", size = 144744 }, - { url = "https://files.pythonhosted.org/packages/ad/06/8499ee5aa7addc6f6d72e068691826ff093329fe59891e83b092ae4c851c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", size = 154993 }, - { url = "https://files.pythonhosted.org/packages/f1/a2/5e4c187680728219254ef107a6949c60ee0e9a916a5dadb148c7ae82459c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597", size = 147382 }, - { url = "https://files.pythonhosted.org/packages/4c/fe/56aca740dda674f0cc1ba1418c4d84534be51f639b5f98f538b332dc9a95/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7", size = 149536 }, - { url = "https://files.pythonhosted.org/packages/53/13/db2e7779f892386b589173dd689c1b1e304621c5792046edd8a978cbf9e0/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f", size = 151349 }, - { url = "https://files.pythonhosted.org/packages/69/35/e52ab9a276186f729bce7a0638585d2982f50402046e4b0faa5d2c3ef2da/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba", size = 146365 }, - { url = "https://files.pythonhosted.org/packages/a6/d8/af7333f732fc2e7635867d56cb7c349c28c7094910c72267586947561b4b/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12", size = 154499 }, - { url = "https://files.pythonhosted.org/packages/7a/3d/a5b2e48acef264d71e036ff30bcc49e51bde80219bb628ba3e00cf59baac/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518", size = 157735 }, - { url = "https://files.pythonhosted.org/packages/85/d8/23e2c112532a29f3eef374375a8684a4f3b8e784f62b01da931186f43494/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5", size = 154786 }, - { url = "https://files.pythonhosted.org/packages/c7/57/93e0169f08ecc20fe82d12254a200dfaceddc1c12a4077bf454ecc597e33/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3", size = 150203 }, - { url = "https://files.pythonhosted.org/packages/2c/9d/9bf2b005138e7e060d7ebdec7503d0ef3240141587651f4b445bdf7286c2/charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471", size = 98436 }, - { url = "https://files.pythonhosted.org/packages/6d/24/5849d46cf4311bbf21b424c443b09b459f5b436b1558c04e45dbb7cc478b/charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e", size = 105772 }, - { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626 }, +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", size = 201818, upload-time = "2025-05-02T08:31:46.725Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", size = 144649, upload-time = "2025-05-02T08:31:48.889Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", size = 155045, upload-time = "2025-05-02T08:31:50.757Z" }, + { url = "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", size = 147356, upload-time = "2025-05-02T08:31:52.634Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", size = 149471, upload-time = "2025-05-02T08:31:56.207Z" }, + { url = "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", size = 151317, upload-time = "2025-05-02T08:31:57.613Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", size = 146368, upload-time = "2025-05-02T08:31:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", size = 154491, upload-time = "2025-05-02T08:32:01.219Z" }, + { url = "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", size = 157695, upload-time = "2025-05-02T08:32:03.045Z" }, + { url = "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", size = 154849, upload-time = "2025-05-02T08:32:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", size = 150091, upload-time = "2025-05-02T08:32:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/1f/79/4b8da9f712bc079c0f16b6d67b099b0b8d808c2292c937f267d816ec5ecc/charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", size = 98445, upload-time = "2025-05-02T08:32:08.66Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d7/96970afb4fb66497a40761cdf7bd4f6fca0fc7bafde3a84f836c1f57a926/charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", size = 105782, upload-time = "2025-05-02T08:32:10.46Z" }, + { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" }, + { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" }, + { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" }, + { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" }, + { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" }, + { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" }, + { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" }, + { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" }, + { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/28/f8/dfb01ff6cc9af38552c69c9027501ff5a5117c4cc18dcd27cb5259fa1888/charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", size = 201671, upload-time = "2025-05-02T08:34:12.696Z" }, + { url = "https://files.pythonhosted.org/packages/32/fb/74e26ee556a9dbfe3bd264289b67be1e6d616329403036f6507bb9f3f29c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", size = 144744, upload-time = "2025-05-02T08:34:14.665Z" }, + { url = "https://files.pythonhosted.org/packages/ad/06/8499ee5aa7addc6f6d72e068691826ff093329fe59891e83b092ae4c851c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", size = 154993, upload-time = "2025-05-02T08:34:17.134Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a2/5e4c187680728219254ef107a6949c60ee0e9a916a5dadb148c7ae82459c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597", size = 147382, upload-time = "2025-05-02T08:34:19.081Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/56aca740dda674f0cc1ba1418c4d84534be51f639b5f98f538b332dc9a95/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7", size = 149536, upload-time = "2025-05-02T08:34:21.073Z" }, + { url = "https://files.pythonhosted.org/packages/53/13/db2e7779f892386b589173dd689c1b1e304621c5792046edd8a978cbf9e0/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f", size = 151349, upload-time = "2025-05-02T08:34:23.193Z" }, + { url = "https://files.pythonhosted.org/packages/69/35/e52ab9a276186f729bce7a0638585d2982f50402046e4b0faa5d2c3ef2da/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba", size = 146365, upload-time = "2025-05-02T08:34:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d8/af7333f732fc2e7635867d56cb7c349c28c7094910c72267586947561b4b/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12", size = 154499, upload-time = "2025-05-02T08:34:27.359Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/a5b2e48acef264d71e036ff30bcc49e51bde80219bb628ba3e00cf59baac/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518", size = 157735, upload-time = "2025-05-02T08:34:29.798Z" }, + { url = "https://files.pythonhosted.org/packages/85/d8/23e2c112532a29f3eef374375a8684a4f3b8e784f62b01da931186f43494/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5", size = 154786, upload-time = "2025-05-02T08:34:31.858Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/93e0169f08ecc20fe82d12254a200dfaceddc1c12a4077bf454ecc597e33/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3", size = 150203, upload-time = "2025-05-02T08:34:33.88Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9d/9bf2b005138e7e060d7ebdec7503d0ef3240141587651f4b445bdf7286c2/charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471", size = 98436, upload-time = "2025-05-02T08:34:35.907Z" }, + { url = "https://files.pythonhosted.org/packages/6d/24/5849d46cf4311bbf21b424c443b09b459f5b436b1558c04e45dbb7cc478b/charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e", size = 105772, upload-time = "2025-05-02T08:34:37.935Z" }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] @@ -222,61 +212,70 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/c8/a2a376a8711c1e11708b9c9972e0c3223f5fc682552c82d8db844393d6ce/cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", size = 744890 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/14/93b69f2af9ba832ad6618a03f8a034a5851dc9a3314336a3d71c252467e1/cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d", size = 4205335 }, - { url = "https://files.pythonhosted.org/packages/67/30/fae1000228634bf0b647fca80403db5ca9e3933b91dd060570689f0bd0f7/cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036", size = 4431487 }, - { url = "https://files.pythonhosted.org/packages/6d/5a/7dffcf8cdf0cb3c2430de7404b327e3db64735747d641fc492539978caeb/cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e", size = 4208922 }, - { url = "https://files.pythonhosted.org/packages/c6/f3/528729726eb6c3060fa3637253430547fbaaea95ab0535ea41baa4a6fbd8/cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2", size = 3900433 }, - { url = "https://files.pythonhosted.org/packages/d9/4a/67ba2e40f619e04d83c32f7e1d484c1538c0800a17c56a22ff07d092ccc1/cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b", size = 4464163 }, - { url = "https://files.pythonhosted.org/packages/7e/9a/b4d5aa83661483ac372464809c4b49b5022dbfe36b12fe9e323ca8512420/cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1", size = 4208687 }, - { url = "https://files.pythonhosted.org/packages/db/b7/a84bdcd19d9c02ec5807f2ec2d1456fd8451592c5ee353816c09250e3561/cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999", size = 4463623 }, - { url = "https://files.pythonhosted.org/packages/d8/84/69707d502d4d905021cac3fb59a316344e9f078b1da7fb43ecde5e10840a/cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750", size = 4332447 }, - { url = "https://files.pythonhosted.org/packages/f3/ee/d4f2ab688e057e90ded24384e34838086a9b09963389a5ba6854b5876598/cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2", size = 4572830 }, - { url = "https://files.pythonhosted.org/packages/fe/51/8c584ed426093aac257462ae62d26ad61ef1cbf5b58d8b67e6e13c39960e/cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637", size = 4195746 }, - { url = "https://files.pythonhosted.org/packages/5c/7d/4b0ca4d7af95a704eef2f8f80a8199ed236aaf185d55385ae1d1610c03c2/cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d", size = 4424456 }, - { url = "https://files.pythonhosted.org/packages/1d/45/5fabacbc6e76ff056f84d9f60eeac18819badf0cefc1b6612ee03d4ab678/cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee", size = 4198495 }, - { url = "https://files.pythonhosted.org/packages/55/b7/ffc9945b290eb0a5d4dab9b7636706e3b5b92f14ee5d9d4449409d010d54/cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff", size = 3885540 }, - { url = "https://files.pythonhosted.org/packages/7f/e3/57b010282346980475e77d414080acdcb3dab9a0be63071efc2041a2c6bd/cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6", size = 4452052 }, - { url = "https://files.pythonhosted.org/packages/37/e6/ddc4ac2558bf2ef517a358df26f45bc774a99bf4653e7ee34b5e749c03e3/cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad", size = 4198024 }, - { url = "https://files.pythonhosted.org/packages/3a/c0/85fa358ddb063ec588aed4a6ea1df57dc3e3bc1712d87c8fa162d02a65fc/cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6", size = 4451442 }, - { url = "https://files.pythonhosted.org/packages/33/67/362d6ec1492596e73da24e669a7fbbaeb1c428d6bf49a29f7a12acffd5dc/cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872", size = 4325038 }, - { url = "https://files.pythonhosted.org/packages/53/75/82a14bf047a96a1b13ebb47fb9811c4f73096cfa2e2b17c86879687f9027/cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4", size = 4560964 }, - { url = "https://files.pythonhosted.org/packages/c4/b9/357f18064ec09d4807800d05a48f92f3b369056a12f995ff79549fbb31f1/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7aad98a25ed8ac917fdd8a9c1e706e5a0956e06c498be1f713b61734333a4507", size = 4143732 }, - { url = "https://files.pythonhosted.org/packages/c4/9c/7f7263b03d5db329093617648b9bd55c953de0b245e64e866e560f9aac07/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3530382a43a0e524bc931f187fc69ef4c42828cf7d7f592f7f249f602b5a4ab0", size = 4385424 }, - { url = "https://files.pythonhosted.org/packages/a6/5a/6aa9d8d5073d5acc0e04e95b2860ef2684b2bd2899d8795fc443013e263b/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:6b613164cb8425e2f8db5849ffb84892e523bf6d26deb8f9bb76ae86181fa12b", size = 4142438 }, - { url = "https://files.pythonhosted.org/packages/42/1c/71c638420f2cdd96d9c2b287fec515faf48679b33a2b583d0f1eda3a3375/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:96d4819e25bf3b685199b304a0029ce4a3caf98947ce8a066c9137cc78ad2c58", size = 4384622 }, - { url = "https://files.pythonhosted.org/packages/28/9a/a7d5bb87d149eb99a5abdc69a41e4e47b8001d767e5f403f78bfaafc7aa7/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4", size = 4146899 }, - { url = "https://files.pythonhosted.org/packages/17/11/9361c2c71c42cc5c465cf294c8030e72fb0c87752bacbd7a3675245e3db3/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349", size = 4388900 }, - { url = "https://files.pythonhosted.org/packages/c0/76/f95b83359012ee0e670da3e41c164a0c256aeedd81886f878911581d852f/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8", size = 4146422 }, - { url = "https://files.pythonhosted.org/packages/09/ad/5429fcc4def93e577a5407988f89cf15305e64920203d4ac14601a9dc876/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862", size = 4388475 }, +sdist = { url = "https://files.pythonhosted.org/packages/fe/c8/a2a376a8711c1e11708b9c9972e0c3223f5fc682552c82d8db844393d6ce/cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57", size = 744890, upload-time = "2025-06-10T00:03:51.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/14/93b69f2af9ba832ad6618a03f8a034a5851dc9a3314336a3d71c252467e1/cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d", size = 4205335, upload-time = "2025-06-10T00:02:41.64Z" }, + { url = "https://files.pythonhosted.org/packages/67/30/fae1000228634bf0b647fca80403db5ca9e3933b91dd060570689f0bd0f7/cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036", size = 4431487, upload-time = "2025-06-10T00:02:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5a/7dffcf8cdf0cb3c2430de7404b327e3db64735747d641fc492539978caeb/cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e", size = 4208922, upload-time = "2025-06-10T00:02:45.334Z" }, + { url = "https://files.pythonhosted.org/packages/c6/f3/528729726eb6c3060fa3637253430547fbaaea95ab0535ea41baa4a6fbd8/cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2", size = 3900433, upload-time = "2025-06-10T00:02:47.359Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4a/67ba2e40f619e04d83c32f7e1d484c1538c0800a17c56a22ff07d092ccc1/cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b", size = 4464163, upload-time = "2025-06-10T00:02:49.412Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9a/b4d5aa83661483ac372464809c4b49b5022dbfe36b12fe9e323ca8512420/cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1", size = 4208687, upload-time = "2025-06-10T00:02:50.976Z" }, + { url = "https://files.pythonhosted.org/packages/db/b7/a84bdcd19d9c02ec5807f2ec2d1456fd8451592c5ee353816c09250e3561/cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999", size = 4463623, upload-time = "2025-06-10T00:02:52.542Z" }, + { url = "https://files.pythonhosted.org/packages/d8/84/69707d502d4d905021cac3fb59a316344e9f078b1da7fb43ecde5e10840a/cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750", size = 4332447, upload-time = "2025-06-10T00:02:54.63Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ee/d4f2ab688e057e90ded24384e34838086a9b09963389a5ba6854b5876598/cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2", size = 4572830, upload-time = "2025-06-10T00:02:56.689Z" }, + { url = "https://files.pythonhosted.org/packages/fe/51/8c584ed426093aac257462ae62d26ad61ef1cbf5b58d8b67e6e13c39960e/cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637", size = 4195746, upload-time = "2025-06-10T00:03:03.94Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7d/4b0ca4d7af95a704eef2f8f80a8199ed236aaf185d55385ae1d1610c03c2/cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d", size = 4424456, upload-time = "2025-06-10T00:03:05.589Z" }, + { url = "https://files.pythonhosted.org/packages/1d/45/5fabacbc6e76ff056f84d9f60eeac18819badf0cefc1b6612ee03d4ab678/cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee", size = 4198495, upload-time = "2025-06-10T00:03:09.172Z" }, + { url = "https://files.pythonhosted.org/packages/55/b7/ffc9945b290eb0a5d4dab9b7636706e3b5b92f14ee5d9d4449409d010d54/cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff", size = 3885540, upload-time = "2025-06-10T00:03:10.835Z" }, + { url = "https://files.pythonhosted.org/packages/7f/e3/57b010282346980475e77d414080acdcb3dab9a0be63071efc2041a2c6bd/cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6", size = 4452052, upload-time = "2025-06-10T00:03:12.448Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/ddc4ac2558bf2ef517a358df26f45bc774a99bf4653e7ee34b5e749c03e3/cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad", size = 4198024, upload-time = "2025-06-10T00:03:13.976Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c0/85fa358ddb063ec588aed4a6ea1df57dc3e3bc1712d87c8fa162d02a65fc/cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6", size = 4451442, upload-time = "2025-06-10T00:03:16.248Z" }, + { url = "https://files.pythonhosted.org/packages/33/67/362d6ec1492596e73da24e669a7fbbaeb1c428d6bf49a29f7a12acffd5dc/cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872", size = 4325038, upload-time = "2025-06-10T00:03:18.4Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/82a14bf047a96a1b13ebb47fb9811c4f73096cfa2e2b17c86879687f9027/cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4", size = 4560964, upload-time = "2025-06-10T00:03:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b9/357f18064ec09d4807800d05a48f92f3b369056a12f995ff79549fbb31f1/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7aad98a25ed8ac917fdd8a9c1e706e5a0956e06c498be1f713b61734333a4507", size = 4143732, upload-time = "2025-06-10T00:03:27.896Z" }, + { url = "https://files.pythonhosted.org/packages/c4/9c/7f7263b03d5db329093617648b9bd55c953de0b245e64e866e560f9aac07/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3530382a43a0e524bc931f187fc69ef4c42828cf7d7f592f7f249f602b5a4ab0", size = 4385424, upload-time = "2025-06-10T00:03:29.992Z" }, + { url = "https://files.pythonhosted.org/packages/a6/5a/6aa9d8d5073d5acc0e04e95b2860ef2684b2bd2899d8795fc443013e263b/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:6b613164cb8425e2f8db5849ffb84892e523bf6d26deb8f9bb76ae86181fa12b", size = 4142438, upload-time = "2025-06-10T00:03:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/42/1c/71c638420f2cdd96d9c2b287fec515faf48679b33a2b583d0f1eda3a3375/cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:96d4819e25bf3b685199b304a0029ce4a3caf98947ce8a066c9137cc78ad2c58", size = 4384622, upload-time = "2025-06-10T00:03:33.491Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a7d5bb87d149eb99a5abdc69a41e4e47b8001d767e5f403f78bfaafc7aa7/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4", size = 4146899, upload-time = "2025-06-10T00:03:38.659Z" }, + { url = "https://files.pythonhosted.org/packages/17/11/9361c2c71c42cc5c465cf294c8030e72fb0c87752bacbd7a3675245e3db3/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349", size = 4388900, upload-time = "2025-06-10T00:03:40.233Z" }, + { url = "https://files.pythonhosted.org/packages/c0/76/f95b83359012ee0e670da3e41c164a0c256aeedd81886f878911581d852f/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8", size = 4146422, upload-time = "2025-06-10T00:03:41.827Z" }, + { url = "https://files.pythonhosted.org/packages/09/ad/5429fcc4def93e577a5407988f89cf15305e64920203d4ac14601a9dc876/cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862", size = 4388475, upload-time = "2025-06-10T00:03:43.493Z" }, ] [[package]] name = "cssselect" version = "1.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/0a/c3ea9573b1dc2e151abfe88c7fe0c26d1892fe6ed02d0cdb30f0d57029d5/cssselect-1.3.0.tar.gz", hash = "sha256:57f8a99424cfab289a1b6a816a43075a4b00948c86b4dcf3ef4ee7e15f7ab0c7", size = 42870 } +sdist = { url = "https://files.pythonhosted.org/packages/72/0a/c3ea9573b1dc2e151abfe88c7fe0c26d1892fe6ed02d0cdb30f0d57029d5/cssselect-1.3.0.tar.gz", hash = "sha256:57f8a99424cfab289a1b6a816a43075a4b00948c86b4dcf3ef4ee7e15f7ab0c7", size = 42870, upload-time = "2025-03-10T09:30:29.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/58/257350f7db99b4ae12b614a36256d9cc870d71d9e451e79c2dc3b23d7c3c/cssselect-1.3.0-py3-none-any.whl", hash = "sha256:56d1bf3e198080cc1667e137bc51de9cadfca259f03c2d4e09037b3e01e30f0d", size = 18786 }, + { url = "https://files.pythonhosted.org/packages/ee/58/257350f7db99b4ae12b614a36256d9cc870d71d9e451e79c2dc3b23d7c3c/cssselect-1.3.0-py3-none-any.whl", hash = "sha256:56d1bf3e198080cc1667e137bc51de9cadfca259f03c2d4e09037b3e01e30f0d", size = 18786, upload-time = "2025-03-10T09:30:28.048Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] [[package]] name = "distlib" version = "0.3.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923, upload-time = "2024-10-09T18:35:47.551Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, ] [[package]] name = "docutils" version = "0.17.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/17/559b4d020f4b46e0287a2eddf2d8ebf76318fd3bd495f1625414b052fdc9/docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125", size = 2016138 } +sdist = { url = "https://files.pythonhosted.org/packages/4c/17/559b4d020f4b46e0287a2eddf2d8ebf76318fd3bd495f1625414b052fdc9/docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125", size = 2016138, upload-time = "2021-04-17T14:13:28.434Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/5e/6003a0d1f37725ec2ebd4046b657abb9372202655f96e76795dca8c0063c/docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61", size = 575533 }, + { url = "https://files.pythonhosted.org/packages/4c/5e/6003a0d1f37725ec2ebd4046b657abb9372202655f96e76795dca8c0063c/docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61", size = 575533, upload-time = "2021-04-17T14:13:24.796Z" }, ] [[package]] @@ -286,18 +285,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] [[package]] name = "filelock" version = "3.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 }, + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, ] [[package]] @@ -307,27 +306,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/11/102da08f88412d875fa2f1a9a469ff7ad4c874b0ca6fed0048fe385bdb3d/id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", size = 15237 } +sdist = { url = "https://files.pythonhosted.org/packages/22/11/102da08f88412d875fa2f1a9a469ff7ad4c874b0ca6fed0048fe385bdb3d/id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", size = 15237, upload-time = "2024-12-04T19:53:05.575Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611 }, + { url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611, upload-time = "2024-12-04T19:53:03.02Z" }, ] [[package]] name = "idna" version = "3.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] [[package]] name = "imagesize" version = "1.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026 } +sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769 }, + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, ] [[package]] @@ -337,18 +336,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641 } +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656 }, + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, ] [[package]] name = "iniconfig" version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] [[package]] @@ -358,9 +357,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780 } +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777 }, + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, ] [[package]] @@ -370,9 +369,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912 } +sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825 }, + { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" }, ] [[package]] @@ -382,18 +381,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/23/9894b3df5d0a6eb44611c36aec777823fc2e07740dabbd0b810e19594013/jaraco_functools-4.1.0.tar.gz", hash = "sha256:70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d", size = 19159 } +sdist = { url = "https://files.pythonhosted.org/packages/ab/23/9894b3df5d0a6eb44611c36aec777823fc2e07740dabbd0b810e19594013/jaraco_functools-4.1.0.tar.gz", hash = "sha256:70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d", size = 19159, upload-time = "2024-09-27T19:47:09.122Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/4f/24b319316142c44283d7540e76c7b5a6dbd5db623abd86bb7b3491c21018/jaraco.functools-4.1.0-py3-none-any.whl", hash = "sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649", size = 10187 }, + { url = "https://files.pythonhosted.org/packages/9f/4f/24b319316142c44283d7540e76c7b5a6dbd5db623abd86bb7b3491c21018/jaraco.functools-4.1.0-py3-none-any.whl", hash = "sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649", size = 10187, upload-time = "2024-09-27T19:47:07.14Z" }, ] [[package]] name = "jeepney" version = "0.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758 } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010 }, + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, ] [[package]] @@ -403,9 +402,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4f/e7/65300e6b32e69768ded990494809106f87da1d436418d5f1367ed3966fd7/Jinja2-2.11.3.tar.gz", hash = "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6", size = 257589 } +sdist = { url = "https://files.pythonhosted.org/packages/4f/e7/65300e6b32e69768ded990494809106f87da1d436418d5f1367ed3966fd7/Jinja2-2.11.3.tar.gz", hash = "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6", size = 257589, upload-time = "2021-01-31T16:33:09.175Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/c2/1eece8c95ddbc9b1aeb64f5783a9e07a286de42191b7204d67b7496ddf35/Jinja2-2.11.3-py2.py3-none-any.whl", hash = "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", size = 125699 }, + { url = "https://files.pythonhosted.org/packages/7e/c2/1eece8c95ddbc9b1aeb64f5783a9e07a286de42191b7204d67b7496ddf35/Jinja2-2.11.3-py2.py3-none-any.whl", hash = "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", size = 125699, upload-time = "2021-01-31T16:33:07.289Z" }, ] [[package]] @@ -421,108 +420,108 @@ dependencies = [ { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, { name = "secretstorage", marker = "sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750 } +sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085 }, + { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" }, ] [[package]] name = "lxml" version = "5.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/3d/14e82fc7c8fb1b7761f7e748fd47e2ec8276d137b6acfe5a4bb73853e08f/lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd", size = 3679479 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/1f/a3b6b74a451ceb84b471caa75c934d2430a4d84395d38ef201d539f38cd1/lxml-5.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e7bc6df34d42322c5289e37e9971d6ed114e3776b45fa879f734bded9d1fea9c", size = 8076838 }, - { url = "https://files.pythonhosted.org/packages/36/af/a567a55b3e47135b4d1f05a1118c24529104c003f95851374b3748139dc1/lxml-5.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6854f8bd8a1536f8a1d9a3655e6354faa6406621cf857dc27b681b69860645c7", size = 4381827 }, - { url = "https://files.pythonhosted.org/packages/50/ba/4ee47d24c675932b3eb5b6de77d0f623c2db6dc466e7a1f199792c5e3e3a/lxml-5.4.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:696ea9e87442467819ac22394ca36cb3d01848dad1be6fac3fb612d3bd5a12cf", size = 5204098 }, - { url = "https://files.pythonhosted.org/packages/f2/0f/b4db6dfebfefe3abafe360f42a3d471881687fd449a0b86b70f1f2683438/lxml-5.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef80aeac414f33c24b3815ecd560cee272786c3adfa5f31316d8b349bfade28", size = 4930261 }, - { url = "https://files.pythonhosted.org/packages/0b/1f/0bb1bae1ce056910f8db81c6aba80fec0e46c98d77c0f59298c70cd362a3/lxml-5.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b9c2754cef6963f3408ab381ea55f47dabc6f78f4b8ebb0f0b25cf1ac1f7609", size = 5529621 }, - { url = "https://files.pythonhosted.org/packages/21/f5/e7b66a533fc4a1e7fa63dd22a1ab2ec4d10319b909211181e1ab3e539295/lxml-5.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a62cc23d754bb449d63ff35334acc9f5c02e6dae830d78dab4dd12b78a524f4", size = 4983231 }, - { url = "https://files.pythonhosted.org/packages/11/39/a38244b669c2d95a6a101a84d3c85ba921fea827e9e5483e93168bf1ccb2/lxml-5.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f82125bc7203c5ae8633a7d5d20bcfdff0ba33e436e4ab0abc026a53a8960b7", size = 5084279 }, - { url = "https://files.pythonhosted.org/packages/db/64/48cac242347a09a07740d6cee7b7fd4663d5c1abd65f2e3c60420e231b27/lxml-5.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b67319b4aef1a6c56576ff544b67a2a6fbd7eaee485b241cabf53115e8908b8f", size = 4927405 }, - { url = "https://files.pythonhosted.org/packages/98/89/97442835fbb01d80b72374f9594fe44f01817d203fa056e9906128a5d896/lxml-5.4.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:a8ef956fce64c8551221f395ba21d0724fed6b9b6242ca4f2f7beb4ce2f41997", size = 5550169 }, - { url = "https://files.pythonhosted.org/packages/f1/97/164ca398ee654eb21f29c6b582685c6c6b9d62d5213abc9b8380278e9c0a/lxml-5.4.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:0a01ce7d8479dce84fc03324e3b0c9c90b1ece9a9bb6a1b6c9025e7e4520e78c", size = 5062691 }, - { url = "https://files.pythonhosted.org/packages/d0/bc/712b96823d7feb53482d2e4f59c090fb18ec7b0d0b476f353b3085893cda/lxml-5.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:91505d3ddebf268bb1588eb0f63821f738d20e1e7f05d3c647a5ca900288760b", size = 5133503 }, - { url = "https://files.pythonhosted.org/packages/d4/55/a62a39e8f9da2a8b6002603475e3c57c870cd9c95fd4b94d4d9ac9036055/lxml-5.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a3bcdde35d82ff385f4ede021df801b5c4a5bcdfb61ea87caabcebfc4945dc1b", size = 4999346 }, - { url = "https://files.pythonhosted.org/packages/ea/47/a393728ae001b92bb1a9e095e570bf71ec7f7fbae7688a4792222e56e5b9/lxml-5.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aea7c06667b987787c7d1f5e1dfcd70419b711cdb47d6b4bb4ad4b76777a0563", size = 5627139 }, - { url = "https://files.pythonhosted.org/packages/5e/5f/9dcaaad037c3e642a7ea64b479aa082968de46dd67a8293c541742b6c9db/lxml-5.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7fb111eef4d05909b82152721a59c1b14d0f365e2be4c742a473c5d7372f4f5", size = 5465609 }, - { url = "https://files.pythonhosted.org/packages/a7/0a/ebcae89edf27e61c45023005171d0ba95cb414ee41c045ae4caf1b8487fd/lxml-5.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43d549b876ce64aa18b2328faff70f5877f8c6dede415f80a2f799d31644d776", size = 5192285 }, - { url = "https://files.pythonhosted.org/packages/42/ad/cc8140ca99add7d85c92db8b2354638ed6d5cc0e917b21d36039cb15a238/lxml-5.4.0-cp310-cp310-win32.whl", hash = "sha256:75133890e40d229d6c5837b0312abbe5bac1c342452cf0e12523477cd3aa21e7", size = 3477507 }, - { url = "https://files.pythonhosted.org/packages/e9/39/597ce090da1097d2aabd2f9ef42187a6c9c8546d67c419ce61b88b336c85/lxml-5.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:de5b4e1088523e2b6f730d0509a9a813355b7f5659d70eb4f319c76beea2e250", size = 3805104 }, - { url = "https://files.pythonhosted.org/packages/81/2d/67693cc8a605a12e5975380d7ff83020dcc759351b5a066e1cced04f797b/lxml-5.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9", size = 8083240 }, - { url = "https://files.pythonhosted.org/packages/73/53/b5a05ab300a808b72e848efd152fe9c022c0181b0a70b8bca1199f1bed26/lxml-5.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7", size = 4387685 }, - { url = "https://files.pythonhosted.org/packages/d8/cb/1a3879c5f512bdcd32995c301886fe082b2edd83c87d41b6d42d89b4ea4d/lxml-5.4.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa", size = 4991164 }, - { url = "https://files.pythonhosted.org/packages/f9/94/bbc66e42559f9d04857071e3b3d0c9abd88579367fd2588a4042f641f57e/lxml-5.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e16c08254b9b6466526bc1828d9370ee6c0d60a4b64836bc3ac2917d1e16df", size = 4746206 }, - { url = "https://files.pythonhosted.org/packages/66/95/34b0679bee435da2d7cae895731700e519a8dfcab499c21662ebe671603e/lxml-5.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7605c1c32c3d6e8c990dd28a0970a3cbbf1429d5b92279e37fda05fb0c92190e", size = 5342144 }, - { url = "https://files.pythonhosted.org/packages/e0/5d/abfcc6ab2fa0be72b2ba938abdae1f7cad4c632f8d552683ea295d55adfb/lxml-5.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecf4c4b83f1ab3d5a7ace10bafcb6f11df6156857a3c418244cef41ca9fa3e44", size = 4825124 }, - { url = "https://files.pythonhosted.org/packages/5a/78/6bd33186c8863b36e084f294fc0a5e5eefe77af95f0663ef33809cc1c8aa/lxml-5.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cef4feae82709eed352cd7e97ae062ef6ae9c7b5dbe3663f104cd2c0e8d94ba", size = 4876520 }, - { url = "https://files.pythonhosted.org/packages/3b/74/4d7ad4839bd0fc64e3d12da74fc9a193febb0fae0ba6ebd5149d4c23176a/lxml-5.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:df53330a3bff250f10472ce96a9af28628ff1f4efc51ccba351a8820bca2a8ba", size = 4765016 }, - { url = "https://files.pythonhosted.org/packages/24/0d/0a98ed1f2471911dadfc541003ac6dd6879fc87b15e1143743ca20f3e973/lxml-5.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:aefe1a7cb852fa61150fcb21a8c8fcea7b58c4cb11fbe59c97a0a4b31cae3c8c", size = 5362884 }, - { url = "https://files.pythonhosted.org/packages/48/de/d4f7e4c39740a6610f0f6959052b547478107967362e8424e1163ec37ae8/lxml-5.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ef5a7178fcc73b7d8c07229e89f8eb45b2908a9238eb90dcfc46571ccf0383b8", size = 4902690 }, - { url = "https://files.pythonhosted.org/packages/07/8c/61763abd242af84f355ca4ef1ee096d3c1b7514819564cce70fd18c22e9a/lxml-5.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d2ed1b3cb9ff1c10e6e8b00941bb2e5bb568b307bfc6b17dffbbe8be5eecba86", size = 4944418 }, - { url = "https://files.pythonhosted.org/packages/f9/c5/6d7e3b63e7e282619193961a570c0a4c8a57fe820f07ca3fe2f6bd86608a/lxml-5.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:72ac9762a9f8ce74c9eed4a4e74306f2f18613a6b71fa065495a67ac227b3056", size = 4827092 }, - { url = "https://files.pythonhosted.org/packages/71/4a/e60a306df54680b103348545706a98a7514a42c8b4fbfdcaa608567bb065/lxml-5.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f5cb182f6396706dc6cc1896dd02b1c889d644c081b0cdec38747573db88a7d7", size = 5418231 }, - { url = "https://files.pythonhosted.org/packages/27/f2/9754aacd6016c930875854f08ac4b192a47fe19565f776a64004aa167521/lxml-5.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3a3178b4873df8ef9457a4875703488eb1622632a9cee6d76464b60e90adbfcd", size = 5261798 }, - { url = "https://files.pythonhosted.org/packages/38/a2/0c49ec6941428b1bd4f280650d7b11a0f91ace9db7de32eb7aa23bcb39ff/lxml-5.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e094ec83694b59d263802ed03a8384594fcce477ce484b0cbcd0008a211ca751", size = 4988195 }, - { url = "https://files.pythonhosted.org/packages/7a/75/87a3963a08eafc46a86c1131c6e28a4de103ba30b5ae903114177352a3d7/lxml-5.4.0-cp311-cp311-win32.whl", hash = "sha256:4329422de653cdb2b72afa39b0aa04252fca9071550044904b2e7036d9d97fe4", size = 3474243 }, - { url = "https://files.pythonhosted.org/packages/fa/f9/1f0964c4f6c2be861c50db380c554fb8befbea98c6404744ce243a3c87ef/lxml-5.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539", size = 3815197 }, - { url = "https://files.pythonhosted.org/packages/f8/4c/d101ace719ca6a4ec043eb516fcfcb1b396a9fccc4fcd9ef593df34ba0d5/lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4", size = 8127392 }, - { url = "https://files.pythonhosted.org/packages/11/84/beddae0cec4dd9ddf46abf156f0af451c13019a0fa25d7445b655ba5ccb7/lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d", size = 4415103 }, - { url = "https://files.pythonhosted.org/packages/d0/25/d0d93a4e763f0462cccd2b8a665bf1e4343dd788c76dcfefa289d46a38a9/lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779", size = 5024224 }, - { url = "https://files.pythonhosted.org/packages/31/ce/1df18fb8f7946e7f3388af378b1f34fcf253b94b9feedb2cec5969da8012/lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e", size = 4769913 }, - { url = "https://files.pythonhosted.org/packages/4e/62/f4a6c60ae7c40d43657f552f3045df05118636be1165b906d3423790447f/lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9", size = 5290441 }, - { url = "https://files.pythonhosted.org/packages/9e/aa/04f00009e1e3a77838c7fc948f161b5d2d5de1136b2b81c712a263829ea4/lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5", size = 4820165 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/e0b2f61fa2404bf0f1fdf1898377e5bd1b74cc9b2cf2c6ba8509b8f27990/lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5", size = 4932580 }, - { url = "https://files.pythonhosted.org/packages/24/a2/8263f351b4ffe0ed3e32ea7b7830f845c795349034f912f490180d88a877/lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4", size = 4759493 }, - { url = "https://files.pythonhosted.org/packages/05/00/41db052f279995c0e35c79d0f0fc9f8122d5b5e9630139c592a0b58c71b4/lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e", size = 5324679 }, - { url = "https://files.pythonhosted.org/packages/1d/be/ee99e6314cdef4587617d3b3b745f9356d9b7dd12a9663c5f3b5734b64ba/lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7", size = 4890691 }, - { url = "https://files.pythonhosted.org/packages/ad/36/239820114bf1d71f38f12208b9c58dec033cbcf80101cde006b9bde5cffd/lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079", size = 4955075 }, - { url = "https://files.pythonhosted.org/packages/d4/e1/1b795cc0b174efc9e13dbd078a9ff79a58728a033142bc6d70a1ee8fc34d/lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20", size = 4838680 }, - { url = "https://files.pythonhosted.org/packages/72/48/3c198455ca108cec5ae3662ae8acd7fd99476812fd712bb17f1b39a0b589/lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8", size = 5391253 }, - { url = "https://files.pythonhosted.org/packages/d6/10/5bf51858971c51ec96cfc13e800a9951f3fd501686f4c18d7d84fe2d6352/lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f", size = 5261651 }, - { url = "https://files.pythonhosted.org/packages/2b/11/06710dd809205377da380546f91d2ac94bad9ff735a72b64ec029f706c85/lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc", size = 5024315 }, - { url = "https://files.pythonhosted.org/packages/f5/b0/15b6217834b5e3a59ebf7f53125e08e318030e8cc0d7310355e6edac98ef/lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f", size = 3486149 }, - { url = "https://files.pythonhosted.org/packages/91/1e/05ddcb57ad2f3069101611bd5f5084157d90861a2ef460bf42f45cced944/lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2", size = 3817095 }, - { url = "https://files.pythonhosted.org/packages/87/cb/2ba1e9dd953415f58548506fa5549a7f373ae55e80c61c9041b7fd09a38a/lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0", size = 8110086 }, - { url = "https://files.pythonhosted.org/packages/b5/3e/6602a4dca3ae344e8609914d6ab22e52ce42e3e1638c10967568c5c1450d/lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de", size = 4404613 }, - { url = "https://files.pythonhosted.org/packages/4c/72/bf00988477d3bb452bef9436e45aeea82bb40cdfb4684b83c967c53909c7/lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76", size = 5012008 }, - { url = "https://files.pythonhosted.org/packages/92/1f/93e42d93e9e7a44b2d3354c462cd784dbaaf350f7976b5d7c3f85d68d1b1/lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d", size = 4760915 }, - { url = "https://files.pythonhosted.org/packages/45/0b/363009390d0b461cf9976a499e83b68f792e4c32ecef092f3f9ef9c4ba54/lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422", size = 5283890 }, - { url = "https://files.pythonhosted.org/packages/19/dc/6056c332f9378ab476c88e301e6549a0454dbee8f0ae16847414f0eccb74/lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551", size = 4812644 }, - { url = "https://files.pythonhosted.org/packages/ee/8a/f8c66bbb23ecb9048a46a5ef9b495fd23f7543df642dabeebcb2eeb66592/lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c", size = 4921817 }, - { url = "https://files.pythonhosted.org/packages/04/57/2e537083c3f381f83d05d9b176f0d838a9e8961f7ed8ddce3f0217179ce3/lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff", size = 4753916 }, - { url = "https://files.pythonhosted.org/packages/d8/80/ea8c4072109a350848f1157ce83ccd9439601274035cd045ac31f47f3417/lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60", size = 5289274 }, - { url = "https://files.pythonhosted.org/packages/b3/47/c4be287c48cdc304483457878a3f22999098b9a95f455e3c4bda7ec7fc72/lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8", size = 4874757 }, - { url = "https://files.pythonhosted.org/packages/2f/04/6ef935dc74e729932e39478e44d8cfe6a83550552eaa072b7c05f6f22488/lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982", size = 4947028 }, - { url = "https://files.pythonhosted.org/packages/cb/f9/c33fc8daa373ef8a7daddb53175289024512b6619bc9de36d77dca3df44b/lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61", size = 4834487 }, - { url = "https://files.pythonhosted.org/packages/8d/30/fc92bb595bcb878311e01b418b57d13900f84c2b94f6eca9e5073ea756e6/lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54", size = 5381688 }, - { url = "https://files.pythonhosted.org/packages/43/d1/3ba7bd978ce28bba8e3da2c2e9d5ae3f8f521ad3f0ca6ea4788d086ba00d/lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b", size = 5242043 }, - { url = "https://files.pythonhosted.org/packages/ee/cd/95fa2201041a610c4d08ddaf31d43b98ecc4b1d74b1e7245b1abdab443cb/lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a", size = 5021569 }, - { url = "https://files.pythonhosted.org/packages/2d/a6/31da006fead660b9512d08d23d31e93ad3477dd47cc42e3285f143443176/lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82", size = 3485270 }, - { url = "https://files.pythonhosted.org/packages/fc/14/c115516c62a7d2499781d2d3d7215218c0731b2c940753bf9f9b7b73924d/lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f", size = 3814606 }, - { url = "https://files.pythonhosted.org/packages/1e/04/acd238222ea25683e43ac7113facc380b3aaf77c53e7d88c4f544cef02ca/lxml-5.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bda3ea44c39eb74e2488297bb39d47186ed01342f0022c8ff407c250ac3f498e", size = 8082189 }, - { url = "https://files.pythonhosted.org/packages/d6/4e/cc7fe9ccb9999cc648492ce970b63c657606aefc7d0fba46b17aa2ba93fb/lxml-5.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9ceaf423b50ecfc23ca00b7f50b64baba85fb3fb91c53e2c9d00bc86150c7e40", size = 4384950 }, - { url = "https://files.pythonhosted.org/packages/56/bf/acd219c489346d0243a30769b9d446b71e5608581db49a18c8d91a669e19/lxml-5.4.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:664cdc733bc87449fe781dbb1f309090966c11cc0c0cd7b84af956a02a8a4729", size = 5209823 }, - { url = "https://files.pythonhosted.org/packages/57/51/ec31cd33175c09aa7b93d101f56eed43d89e15504455d884d021df7166a7/lxml-5.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67ed8a40665b84d161bae3181aa2763beea3747f748bca5874b4af4d75998f87", size = 4931808 }, - { url = "https://files.pythonhosted.org/packages/e5/68/865d229f191514da1777125598d028dc88a5ea300d68c30e1f120bfd01bd/lxml-5.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b4a3bd174cc9cdaa1afbc4620c049038b441d6ba07629d89a83b408e54c35cd", size = 5086067 }, - { url = "https://files.pythonhosted.org/packages/82/01/4c958c5848b4e263cd9e83dff6b49f975a5a0854feb1070dfe0bdcdf70a0/lxml-5.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:b0989737a3ba6cf2a16efb857fb0dfa20bc5c542737fddb6d893fde48be45433", size = 4929026 }, - { url = "https://files.pythonhosted.org/packages/55/31/5327d8af74d7f35e645b40ae6658761e1fee59ebecaa6a8d295e495c2ca9/lxml-5.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:dc0af80267edc68adf85f2a5d9be1cdf062f973db6790c1d065e45025fa26140", size = 5134245 }, - { url = "https://files.pythonhosted.org/packages/6f/c9/204eba2400beb0016dacc2c5335ecb1e37f397796683ffdb7f471e86bddb/lxml-5.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:639978bccb04c42677db43c79bdaa23785dc7f9b83bfd87570da8207872f1ce5", size = 5001020 }, - { url = "https://files.pythonhosted.org/packages/07/53/979165f50a853dab1cf3b9e53105032d55f85c5993f94afc4d9a61a22877/lxml-5.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5a99d86351f9c15e4a901fc56404b485b1462039db59288b203f8c629260a142", size = 5192346 }, - { url = "https://files.pythonhosted.org/packages/17/2b/f37b5ae28949143f863ba3066b30eede6107fc9a503bd0d01677d4e2a1e0/lxml-5.4.0-cp39-cp39-win32.whl", hash = "sha256:3e6d5557989cdc3ebb5302bbdc42b439733a841891762ded9514e74f60319ad6", size = 3478275 }, - { url = "https://files.pythonhosted.org/packages/9a/d5/b795a183680126147665a8eeda8e802c180f2f7661aa9a550bba5bcdae63/lxml-5.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:a8c9b7f16b63e65bbba889acb436a1034a82d34fa09752d754f88d708eca80e1", size = 3806275 }, - { url = "https://files.pythonhosted.org/packages/c6/b0/e4d1cbb8c078bc4ae44de9c6a79fec4e2b4151b1b4d50af71d799e76b177/lxml-5.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1b717b00a71b901b4667226bba282dd462c42ccf618ade12f9ba3674e1fabc55", size = 3892319 }, - { url = "https://files.pythonhosted.org/packages/5b/aa/e2bdefba40d815059bcb60b371a36fbfcce970a935370e1b367ba1cc8f74/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27a9ded0f0b52098ff89dd4c418325b987feed2ea5cc86e8860b0f844285d740", size = 4211614 }, - { url = "https://files.pythonhosted.org/packages/3c/5f/91ff89d1e092e7cfdd8453a939436ac116db0a665e7f4be0cd8e65c7dc5a/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b7ce10634113651d6f383aa712a194179dcd496bd8c41e191cec2099fa09de5", size = 4306273 }, - { url = "https://files.pythonhosted.org/packages/be/7c/8c3f15df2ca534589717bfd19d1e3482167801caedfa4d90a575facf68a6/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53370c26500d22b45182f98847243efb518d268374a9570409d2e2276232fd37", size = 4208552 }, - { url = "https://files.pythonhosted.org/packages/7d/d8/9567afb1665f64d73fc54eb904e418d1138d7f011ed00647121b4dd60b38/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6364038c519dffdbe07e3cf42e6a7f8b90c275d4d1617a69bb59734c1a2d571", size = 4331091 }, - { url = "https://files.pythonhosted.org/packages/f1/ab/fdbbd91d8d82bf1a723ba88ec3e3d76c022b53c391b0c13cad441cdb8f9e/lxml-5.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b12cb6527599808ada9eb2cd6e0e7d3d8f13fe7bbb01c6311255a15ded4c7ab4", size = 3487862 }, - { url = "https://files.pythonhosted.org/packages/ad/fb/d19b67e4bb63adc20574ba3476cf763b3514df1a37551084b890254e4b15/lxml-5.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9459e6892f59ecea2e2584ee1058f5d8f629446eab52ba2305ae13a32a059530", size = 3891034 }, - { url = "https://files.pythonhosted.org/packages/c9/5d/6e1033ee0cdb2f9bc93164f9df14e42cb5bbf1bbed3bf67f687de2763104/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47fb24cc0f052f0576ea382872b3fc7e1f7e3028e53299ea751839418ade92a6", size = 4207420 }, - { url = "https://files.pythonhosted.org/packages/f3/4b/23ac79efc32d913259d66672c5f93daac7750a3d97cdc1c1a9a5d1c1b46c/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50441c9de951a153c698b9b99992e806b71c1f36d14b154592580ff4a9d0d877", size = 4305106 }, - { url = "https://files.pythonhosted.org/packages/a4/7a/fe558bee63a62f7a75a52111c0a94556c1c1bdcf558cd7d52861de558759/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ab339536aa798b1e17750733663d272038bf28069761d5be57cb4a9b0137b4f8", size = 4205587 }, - { url = "https://files.pythonhosted.org/packages/ed/5b/3207e6bd8d67c952acfec6bac9d1fa0ee353202e7c40b335ebe00879ab7d/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9776af1aad5a4b4a1317242ee2bea51da54b2a7b7b48674be736d463c999f37d", size = 4329077 }, - { url = "https://files.pythonhosted.org/packages/a1/25/d381abcfd00102d3304aa191caab62f6e3bcbac93ee248771db6be153dfd/lxml-5.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:63e7968ff83da2eb6fdda967483a7a023aa497d85ad8f05c3ad9b1f2e8c84987", size = 3486416 }, +sdist = { url = "https://files.pythonhosted.org/packages/76/3d/14e82fc7c8fb1b7761f7e748fd47e2ec8276d137b6acfe5a4bb73853e08f/lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd", size = 3679479, upload-time = "2025-04-23T01:50:29.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/1f/a3b6b74a451ceb84b471caa75c934d2430a4d84395d38ef201d539f38cd1/lxml-5.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e7bc6df34d42322c5289e37e9971d6ed114e3776b45fa879f734bded9d1fea9c", size = 8076838, upload-time = "2025-04-23T01:44:29.325Z" }, + { url = "https://files.pythonhosted.org/packages/36/af/a567a55b3e47135b4d1f05a1118c24529104c003f95851374b3748139dc1/lxml-5.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6854f8bd8a1536f8a1d9a3655e6354faa6406621cf857dc27b681b69860645c7", size = 4381827, upload-time = "2025-04-23T01:44:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/50/ba/4ee47d24c675932b3eb5b6de77d0f623c2db6dc466e7a1f199792c5e3e3a/lxml-5.4.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:696ea9e87442467819ac22394ca36cb3d01848dad1be6fac3fb612d3bd5a12cf", size = 5204098, upload-time = "2025-04-23T01:44:35.809Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0f/b4db6dfebfefe3abafe360f42a3d471881687fd449a0b86b70f1f2683438/lxml-5.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef80aeac414f33c24b3815ecd560cee272786c3adfa5f31316d8b349bfade28", size = 4930261, upload-time = "2025-04-23T01:44:38.271Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/0bb1bae1ce056910f8db81c6aba80fec0e46c98d77c0f59298c70cd362a3/lxml-5.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b9c2754cef6963f3408ab381ea55f47dabc6f78f4b8ebb0f0b25cf1ac1f7609", size = 5529621, upload-time = "2025-04-23T01:44:40.921Z" }, + { url = "https://files.pythonhosted.org/packages/21/f5/e7b66a533fc4a1e7fa63dd22a1ab2ec4d10319b909211181e1ab3e539295/lxml-5.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a62cc23d754bb449d63ff35334acc9f5c02e6dae830d78dab4dd12b78a524f4", size = 4983231, upload-time = "2025-04-23T01:44:43.871Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/a38244b669c2d95a6a101a84d3c85ba921fea827e9e5483e93168bf1ccb2/lxml-5.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f82125bc7203c5ae8633a7d5d20bcfdff0ba33e436e4ab0abc026a53a8960b7", size = 5084279, upload-time = "2025-04-23T01:44:46.632Z" }, + { url = "https://files.pythonhosted.org/packages/db/64/48cac242347a09a07740d6cee7b7fd4663d5c1abd65f2e3c60420e231b27/lxml-5.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b67319b4aef1a6c56576ff544b67a2a6fbd7eaee485b241cabf53115e8908b8f", size = 4927405, upload-time = "2025-04-23T01:44:49.843Z" }, + { url = "https://files.pythonhosted.org/packages/98/89/97442835fbb01d80b72374f9594fe44f01817d203fa056e9906128a5d896/lxml-5.4.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:a8ef956fce64c8551221f395ba21d0724fed6b9b6242ca4f2f7beb4ce2f41997", size = 5550169, upload-time = "2025-04-23T01:44:52.791Z" }, + { url = "https://files.pythonhosted.org/packages/f1/97/164ca398ee654eb21f29c6b582685c6c6b9d62d5213abc9b8380278e9c0a/lxml-5.4.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:0a01ce7d8479dce84fc03324e3b0c9c90b1ece9a9bb6a1b6c9025e7e4520e78c", size = 5062691, upload-time = "2025-04-23T01:44:56.108Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bc/712b96823d7feb53482d2e4f59c090fb18ec7b0d0b476f353b3085893cda/lxml-5.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:91505d3ddebf268bb1588eb0f63821f738d20e1e7f05d3c647a5ca900288760b", size = 5133503, upload-time = "2025-04-23T01:44:59.222Z" }, + { url = "https://files.pythonhosted.org/packages/d4/55/a62a39e8f9da2a8b6002603475e3c57c870cd9c95fd4b94d4d9ac9036055/lxml-5.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a3bcdde35d82ff385f4ede021df801b5c4a5bcdfb61ea87caabcebfc4945dc1b", size = 4999346, upload-time = "2025-04-23T01:45:02.088Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/a393728ae001b92bb1a9e095e570bf71ec7f7fbae7688a4792222e56e5b9/lxml-5.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aea7c06667b987787c7d1f5e1dfcd70419b711cdb47d6b4bb4ad4b76777a0563", size = 5627139, upload-time = "2025-04-23T01:45:04.582Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5f/9dcaaad037c3e642a7ea64b479aa082968de46dd67a8293c541742b6c9db/lxml-5.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7fb111eef4d05909b82152721a59c1b14d0f365e2be4c742a473c5d7372f4f5", size = 5465609, upload-time = "2025-04-23T01:45:07.649Z" }, + { url = "https://files.pythonhosted.org/packages/a7/0a/ebcae89edf27e61c45023005171d0ba95cb414ee41c045ae4caf1b8487fd/lxml-5.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43d549b876ce64aa18b2328faff70f5877f8c6dede415f80a2f799d31644d776", size = 5192285, upload-time = "2025-04-23T01:45:10.456Z" }, + { url = "https://files.pythonhosted.org/packages/42/ad/cc8140ca99add7d85c92db8b2354638ed6d5cc0e917b21d36039cb15a238/lxml-5.4.0-cp310-cp310-win32.whl", hash = "sha256:75133890e40d229d6c5837b0312abbe5bac1c342452cf0e12523477cd3aa21e7", size = 3477507, upload-time = "2025-04-23T01:45:12.474Z" }, + { url = "https://files.pythonhosted.org/packages/e9/39/597ce090da1097d2aabd2f9ef42187a6c9c8546d67c419ce61b88b336c85/lxml-5.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:de5b4e1088523e2b6f730d0509a9a813355b7f5659d70eb4f319c76beea2e250", size = 3805104, upload-time = "2025-04-23T01:45:15.104Z" }, + { url = "https://files.pythonhosted.org/packages/81/2d/67693cc8a605a12e5975380d7ff83020dcc759351b5a066e1cced04f797b/lxml-5.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9", size = 8083240, upload-time = "2025-04-23T01:45:18.566Z" }, + { url = "https://files.pythonhosted.org/packages/73/53/b5a05ab300a808b72e848efd152fe9c022c0181b0a70b8bca1199f1bed26/lxml-5.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7", size = 4387685, upload-time = "2025-04-23T01:45:21.387Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/1a3879c5f512bdcd32995c301886fe082b2edd83c87d41b6d42d89b4ea4d/lxml-5.4.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa", size = 4991164, upload-time = "2025-04-23T01:45:23.849Z" }, + { url = "https://files.pythonhosted.org/packages/f9/94/bbc66e42559f9d04857071e3b3d0c9abd88579367fd2588a4042f641f57e/lxml-5.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e16c08254b9b6466526bc1828d9370ee6c0d60a4b64836bc3ac2917d1e16df", size = 4746206, upload-time = "2025-04-23T01:45:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/66/95/34b0679bee435da2d7cae895731700e519a8dfcab499c21662ebe671603e/lxml-5.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7605c1c32c3d6e8c990dd28a0970a3cbbf1429d5b92279e37fda05fb0c92190e", size = 5342144, upload-time = "2025-04-23T01:45:28.939Z" }, + { url = "https://files.pythonhosted.org/packages/e0/5d/abfcc6ab2fa0be72b2ba938abdae1f7cad4c632f8d552683ea295d55adfb/lxml-5.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecf4c4b83f1ab3d5a7ace10bafcb6f11df6156857a3c418244cef41ca9fa3e44", size = 4825124, upload-time = "2025-04-23T01:45:31.361Z" }, + { url = "https://files.pythonhosted.org/packages/5a/78/6bd33186c8863b36e084f294fc0a5e5eefe77af95f0663ef33809cc1c8aa/lxml-5.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cef4feae82709eed352cd7e97ae062ef6ae9c7b5dbe3663f104cd2c0e8d94ba", size = 4876520, upload-time = "2025-04-23T01:45:34.191Z" }, + { url = "https://files.pythonhosted.org/packages/3b/74/4d7ad4839bd0fc64e3d12da74fc9a193febb0fae0ba6ebd5149d4c23176a/lxml-5.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:df53330a3bff250f10472ce96a9af28628ff1f4efc51ccba351a8820bca2a8ba", size = 4765016, upload-time = "2025-04-23T01:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/24/0d/0a98ed1f2471911dadfc541003ac6dd6879fc87b15e1143743ca20f3e973/lxml-5.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:aefe1a7cb852fa61150fcb21a8c8fcea7b58c4cb11fbe59c97a0a4b31cae3c8c", size = 5362884, upload-time = "2025-04-23T01:45:39.291Z" }, + { url = "https://files.pythonhosted.org/packages/48/de/d4f7e4c39740a6610f0f6959052b547478107967362e8424e1163ec37ae8/lxml-5.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ef5a7178fcc73b7d8c07229e89f8eb45b2908a9238eb90dcfc46571ccf0383b8", size = 4902690, upload-time = "2025-04-23T01:45:42.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/8c/61763abd242af84f355ca4ef1ee096d3c1b7514819564cce70fd18c22e9a/lxml-5.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d2ed1b3cb9ff1c10e6e8b00941bb2e5bb568b307bfc6b17dffbbe8be5eecba86", size = 4944418, upload-time = "2025-04-23T01:45:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/6d7e3b63e7e282619193961a570c0a4c8a57fe820f07ca3fe2f6bd86608a/lxml-5.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:72ac9762a9f8ce74c9eed4a4e74306f2f18613a6b71fa065495a67ac227b3056", size = 4827092, upload-time = "2025-04-23T01:45:48.943Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/e60a306df54680b103348545706a98a7514a42c8b4fbfdcaa608567bb065/lxml-5.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f5cb182f6396706dc6cc1896dd02b1c889d644c081b0cdec38747573db88a7d7", size = 5418231, upload-time = "2025-04-23T01:45:51.481Z" }, + { url = "https://files.pythonhosted.org/packages/27/f2/9754aacd6016c930875854f08ac4b192a47fe19565f776a64004aa167521/lxml-5.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3a3178b4873df8ef9457a4875703488eb1622632a9cee6d76464b60e90adbfcd", size = 5261798, upload-time = "2025-04-23T01:45:54.146Z" }, + { url = "https://files.pythonhosted.org/packages/38/a2/0c49ec6941428b1bd4f280650d7b11a0f91ace9db7de32eb7aa23bcb39ff/lxml-5.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e094ec83694b59d263802ed03a8384594fcce477ce484b0cbcd0008a211ca751", size = 4988195, upload-time = "2025-04-23T01:45:56.685Z" }, + { url = "https://files.pythonhosted.org/packages/7a/75/87a3963a08eafc46a86c1131c6e28a4de103ba30b5ae903114177352a3d7/lxml-5.4.0-cp311-cp311-win32.whl", hash = "sha256:4329422de653cdb2b72afa39b0aa04252fca9071550044904b2e7036d9d97fe4", size = 3474243, upload-time = "2025-04-23T01:45:58.863Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/1f0964c4f6c2be861c50db380c554fb8befbea98c6404744ce243a3c87ef/lxml-5.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539", size = 3815197, upload-time = "2025-04-23T01:46:01.096Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/d101ace719ca6a4ec043eb516fcfcb1b396a9fccc4fcd9ef593df34ba0d5/lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4", size = 8127392, upload-time = "2025-04-23T01:46:04.09Z" }, + { url = "https://files.pythonhosted.org/packages/11/84/beddae0cec4dd9ddf46abf156f0af451c13019a0fa25d7445b655ba5ccb7/lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d", size = 4415103, upload-time = "2025-04-23T01:46:07.227Z" }, + { url = "https://files.pythonhosted.org/packages/d0/25/d0d93a4e763f0462cccd2b8a665bf1e4343dd788c76dcfefa289d46a38a9/lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779", size = 5024224, upload-time = "2025-04-23T01:46:10.237Z" }, + { url = "https://files.pythonhosted.org/packages/31/ce/1df18fb8f7946e7f3388af378b1f34fcf253b94b9feedb2cec5969da8012/lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e", size = 4769913, upload-time = "2025-04-23T01:46:12.757Z" }, + { url = "https://files.pythonhosted.org/packages/4e/62/f4a6c60ae7c40d43657f552f3045df05118636be1165b906d3423790447f/lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9", size = 5290441, upload-time = "2025-04-23T01:46:16.037Z" }, + { url = "https://files.pythonhosted.org/packages/9e/aa/04f00009e1e3a77838c7fc948f161b5d2d5de1136b2b81c712a263829ea4/lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5", size = 4820165, upload-time = "2025-04-23T01:46:19.137Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/e0b2f61fa2404bf0f1fdf1898377e5bd1b74cc9b2cf2c6ba8509b8f27990/lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5", size = 4932580, upload-time = "2025-04-23T01:46:21.963Z" }, + { url = "https://files.pythonhosted.org/packages/24/a2/8263f351b4ffe0ed3e32ea7b7830f845c795349034f912f490180d88a877/lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4", size = 4759493, upload-time = "2025-04-23T01:46:24.316Z" }, + { url = "https://files.pythonhosted.org/packages/05/00/41db052f279995c0e35c79d0f0fc9f8122d5b5e9630139c592a0b58c71b4/lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e", size = 5324679, upload-time = "2025-04-23T01:46:27.097Z" }, + { url = "https://files.pythonhosted.org/packages/1d/be/ee99e6314cdef4587617d3b3b745f9356d9b7dd12a9663c5f3b5734b64ba/lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7", size = 4890691, upload-time = "2025-04-23T01:46:30.009Z" }, + { url = "https://files.pythonhosted.org/packages/ad/36/239820114bf1d71f38f12208b9c58dec033cbcf80101cde006b9bde5cffd/lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079", size = 4955075, upload-time = "2025-04-23T01:46:32.33Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e1/1b795cc0b174efc9e13dbd078a9ff79a58728a033142bc6d70a1ee8fc34d/lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20", size = 4838680, upload-time = "2025-04-23T01:46:34.852Z" }, + { url = "https://files.pythonhosted.org/packages/72/48/3c198455ca108cec5ae3662ae8acd7fd99476812fd712bb17f1b39a0b589/lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8", size = 5391253, upload-time = "2025-04-23T01:46:37.608Z" }, + { url = "https://files.pythonhosted.org/packages/d6/10/5bf51858971c51ec96cfc13e800a9951f3fd501686f4c18d7d84fe2d6352/lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f", size = 5261651, upload-time = "2025-04-23T01:46:40.183Z" }, + { url = "https://files.pythonhosted.org/packages/2b/11/06710dd809205377da380546f91d2ac94bad9ff735a72b64ec029f706c85/lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc", size = 5024315, upload-time = "2025-04-23T01:46:43.333Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b0/15b6217834b5e3a59ebf7f53125e08e318030e8cc0d7310355e6edac98ef/lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f", size = 3486149, upload-time = "2025-04-23T01:46:45.684Z" }, + { url = "https://files.pythonhosted.org/packages/91/1e/05ddcb57ad2f3069101611bd5f5084157d90861a2ef460bf42f45cced944/lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2", size = 3817095, upload-time = "2025-04-23T01:46:48.521Z" }, + { url = "https://files.pythonhosted.org/packages/87/cb/2ba1e9dd953415f58548506fa5549a7f373ae55e80c61c9041b7fd09a38a/lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0", size = 8110086, upload-time = "2025-04-23T01:46:52.218Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3e/6602a4dca3ae344e8609914d6ab22e52ce42e3e1638c10967568c5c1450d/lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de", size = 4404613, upload-time = "2025-04-23T01:46:55.281Z" }, + { url = "https://files.pythonhosted.org/packages/4c/72/bf00988477d3bb452bef9436e45aeea82bb40cdfb4684b83c967c53909c7/lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76", size = 5012008, upload-time = "2025-04-23T01:46:57.817Z" }, + { url = "https://files.pythonhosted.org/packages/92/1f/93e42d93e9e7a44b2d3354c462cd784dbaaf350f7976b5d7c3f85d68d1b1/lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d", size = 4760915, upload-time = "2025-04-23T01:47:00.745Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/363009390d0b461cf9976a499e83b68f792e4c32ecef092f3f9ef9c4ba54/lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422", size = 5283890, upload-time = "2025-04-23T01:47:04.702Z" }, + { url = "https://files.pythonhosted.org/packages/19/dc/6056c332f9378ab476c88e301e6549a0454dbee8f0ae16847414f0eccb74/lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551", size = 4812644, upload-time = "2025-04-23T01:47:07.833Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8a/f8c66bbb23ecb9048a46a5ef9b495fd23f7543df642dabeebcb2eeb66592/lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c", size = 4921817, upload-time = "2025-04-23T01:47:10.317Z" }, + { url = "https://files.pythonhosted.org/packages/04/57/2e537083c3f381f83d05d9b176f0d838a9e8961f7ed8ddce3f0217179ce3/lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff", size = 4753916, upload-time = "2025-04-23T01:47:12.823Z" }, + { url = "https://files.pythonhosted.org/packages/d8/80/ea8c4072109a350848f1157ce83ccd9439601274035cd045ac31f47f3417/lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60", size = 5289274, upload-time = "2025-04-23T01:47:15.916Z" }, + { url = "https://files.pythonhosted.org/packages/b3/47/c4be287c48cdc304483457878a3f22999098b9a95f455e3c4bda7ec7fc72/lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8", size = 4874757, upload-time = "2025-04-23T01:47:19.793Z" }, + { url = "https://files.pythonhosted.org/packages/2f/04/6ef935dc74e729932e39478e44d8cfe6a83550552eaa072b7c05f6f22488/lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982", size = 4947028, upload-time = "2025-04-23T01:47:22.401Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f9/c33fc8daa373ef8a7daddb53175289024512b6619bc9de36d77dca3df44b/lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61", size = 4834487, upload-time = "2025-04-23T01:47:25.513Z" }, + { url = "https://files.pythonhosted.org/packages/8d/30/fc92bb595bcb878311e01b418b57d13900f84c2b94f6eca9e5073ea756e6/lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54", size = 5381688, upload-time = "2025-04-23T01:47:28.454Z" }, + { url = "https://files.pythonhosted.org/packages/43/d1/3ba7bd978ce28bba8e3da2c2e9d5ae3f8f521ad3f0ca6ea4788d086ba00d/lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b", size = 5242043, upload-time = "2025-04-23T01:47:31.208Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cd/95fa2201041a610c4d08ddaf31d43b98ecc4b1d74b1e7245b1abdab443cb/lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a", size = 5021569, upload-time = "2025-04-23T01:47:33.805Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a6/31da006fead660b9512d08d23d31e93ad3477dd47cc42e3285f143443176/lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82", size = 3485270, upload-time = "2025-04-23T01:47:36.133Z" }, + { url = "https://files.pythonhosted.org/packages/fc/14/c115516c62a7d2499781d2d3d7215218c0731b2c940753bf9f9b7b73924d/lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f", size = 3814606, upload-time = "2025-04-23T01:47:39.028Z" }, + { url = "https://files.pythonhosted.org/packages/1e/04/acd238222ea25683e43ac7113facc380b3aaf77c53e7d88c4f544cef02ca/lxml-5.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bda3ea44c39eb74e2488297bb39d47186ed01342f0022c8ff407c250ac3f498e", size = 8082189, upload-time = "2025-04-23T01:48:51.829Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4e/cc7fe9ccb9999cc648492ce970b63c657606aefc7d0fba46b17aa2ba93fb/lxml-5.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9ceaf423b50ecfc23ca00b7f50b64baba85fb3fb91c53e2c9d00bc86150c7e40", size = 4384950, upload-time = "2025-04-23T01:48:54.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/bf/acd219c489346d0243a30769b9d446b71e5608581db49a18c8d91a669e19/lxml-5.4.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:664cdc733bc87449fe781dbb1f309090966c11cc0c0cd7b84af956a02a8a4729", size = 5209823, upload-time = "2025-04-23T01:48:57.192Z" }, + { url = "https://files.pythonhosted.org/packages/57/51/ec31cd33175c09aa7b93d101f56eed43d89e15504455d884d021df7166a7/lxml-5.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67ed8a40665b84d161bae3181aa2763beea3747f748bca5874b4af4d75998f87", size = 4931808, upload-time = "2025-04-23T01:48:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/e5/68/865d229f191514da1777125598d028dc88a5ea300d68c30e1f120bfd01bd/lxml-5.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b4a3bd174cc9cdaa1afbc4620c049038b441d6ba07629d89a83b408e54c35cd", size = 5086067, upload-time = "2025-04-23T01:49:02.887Z" }, + { url = "https://files.pythonhosted.org/packages/82/01/4c958c5848b4e263cd9e83dff6b49f975a5a0854feb1070dfe0bdcdf70a0/lxml-5.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:b0989737a3ba6cf2a16efb857fb0dfa20bc5c542737fddb6d893fde48be45433", size = 4929026, upload-time = "2025-04-23T01:49:05.624Z" }, + { url = "https://files.pythonhosted.org/packages/55/31/5327d8af74d7f35e645b40ae6658761e1fee59ebecaa6a8d295e495c2ca9/lxml-5.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:dc0af80267edc68adf85f2a5d9be1cdf062f973db6790c1d065e45025fa26140", size = 5134245, upload-time = "2025-04-23T01:49:08.918Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c9/204eba2400beb0016dacc2c5335ecb1e37f397796683ffdb7f471e86bddb/lxml-5.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:639978bccb04c42677db43c79bdaa23785dc7f9b83bfd87570da8207872f1ce5", size = 5001020, upload-time = "2025-04-23T01:49:11.643Z" }, + { url = "https://files.pythonhosted.org/packages/07/53/979165f50a853dab1cf3b9e53105032d55f85c5993f94afc4d9a61a22877/lxml-5.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5a99d86351f9c15e4a901fc56404b485b1462039db59288b203f8c629260a142", size = 5192346, upload-time = "2025-04-23T01:49:14.868Z" }, + { url = "https://files.pythonhosted.org/packages/17/2b/f37b5ae28949143f863ba3066b30eede6107fc9a503bd0d01677d4e2a1e0/lxml-5.4.0-cp39-cp39-win32.whl", hash = "sha256:3e6d5557989cdc3ebb5302bbdc42b439733a841891762ded9514e74f60319ad6", size = 3478275, upload-time = "2025-04-23T01:49:17.249Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d5/b795a183680126147665a8eeda8e802c180f2f7661aa9a550bba5bcdae63/lxml-5.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:a8c9b7f16b63e65bbba889acb436a1034a82d34fa09752d754f88d708eca80e1", size = 3806275, upload-time = "2025-04-23T01:49:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b0/e4d1cbb8c078bc4ae44de9c6a79fec4e2b4151b1b4d50af71d799e76b177/lxml-5.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1b717b00a71b901b4667226bba282dd462c42ccf618ade12f9ba3674e1fabc55", size = 3892319, upload-time = "2025-04-23T01:49:22.069Z" }, + { url = "https://files.pythonhosted.org/packages/5b/aa/e2bdefba40d815059bcb60b371a36fbfcce970a935370e1b367ba1cc8f74/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27a9ded0f0b52098ff89dd4c418325b987feed2ea5cc86e8860b0f844285d740", size = 4211614, upload-time = "2025-04-23T01:49:24.599Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/91ff89d1e092e7cfdd8453a939436ac116db0a665e7f4be0cd8e65c7dc5a/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b7ce10634113651d6f383aa712a194179dcd496bd8c41e191cec2099fa09de5", size = 4306273, upload-time = "2025-04-23T01:49:27.355Z" }, + { url = "https://files.pythonhosted.org/packages/be/7c/8c3f15df2ca534589717bfd19d1e3482167801caedfa4d90a575facf68a6/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53370c26500d22b45182f98847243efb518d268374a9570409d2e2276232fd37", size = 4208552, upload-time = "2025-04-23T01:49:29.949Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/9567afb1665f64d73fc54eb904e418d1138d7f011ed00647121b4dd60b38/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6364038c519dffdbe07e3cf42e6a7f8b90c275d4d1617a69bb59734c1a2d571", size = 4331091, upload-time = "2025-04-23T01:49:32.842Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ab/fdbbd91d8d82bf1a723ba88ec3e3d76c022b53c391b0c13cad441cdb8f9e/lxml-5.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b12cb6527599808ada9eb2cd6e0e7d3d8f13fe7bbb01c6311255a15ded4c7ab4", size = 3487862, upload-time = "2025-04-23T01:49:36.296Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fb/d19b67e4bb63adc20574ba3476cf763b3514df1a37551084b890254e4b15/lxml-5.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9459e6892f59ecea2e2584ee1058f5d8f629446eab52ba2305ae13a32a059530", size = 3891034, upload-time = "2025-04-23T01:50:12.71Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5d/6e1033ee0cdb2f9bc93164f9df14e42cb5bbf1bbed3bf67f687de2763104/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47fb24cc0f052f0576ea382872b3fc7e1f7e3028e53299ea751839418ade92a6", size = 4207420, upload-time = "2025-04-23T01:50:15.281Z" }, + { url = "https://files.pythonhosted.org/packages/f3/4b/23ac79efc32d913259d66672c5f93daac7750a3d97cdc1c1a9a5d1c1b46c/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50441c9de951a153c698b9b99992e806b71c1f36d14b154592580ff4a9d0d877", size = 4305106, upload-time = "2025-04-23T01:50:17.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7a/fe558bee63a62f7a75a52111c0a94556c1c1bdcf558cd7d52861de558759/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ab339536aa798b1e17750733663d272038bf28069761d5be57cb4a9b0137b4f8", size = 4205587, upload-time = "2025-04-23T01:50:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5b/3207e6bd8d67c952acfec6bac9d1fa0ee353202e7c40b335ebe00879ab7d/lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9776af1aad5a4b4a1317242ee2bea51da54b2a7b7b48674be736d463c999f37d", size = 4329077, upload-time = "2025-04-23T01:50:23.996Z" }, + { url = "https://files.pythonhosted.org/packages/a1/25/d381abcfd00102d3304aa191caab62f6e3bcbac93ee248771db6be153dfd/lxml-5.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:63e7968ff83da2eb6fdda967483a7a023aa497d85ad8f05c3ad9b1f2e8c84987", size = 3486416, upload-time = "2025-04-23T01:50:26.388Z" }, ] [[package]] @@ -532,91 +531,91 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] [[package]] name = "markupsafe" version = "0.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/41/bae1254e0396c0cc8cf1751cb7d9afc90a602353695af5952530482c963f/MarkupSafe-0.23.tar.gz", hash = "sha256:a4ec1aff59b95a14b45eb2e23761a0179e98319da5a7eb76b56ea8cdc7b871c3", size = 13416 } +sdist = { url = "https://files.pythonhosted.org/packages/c0/41/bae1254e0396c0cc8cf1751cb7d9afc90a602353695af5952530482c963f/MarkupSafe-0.23.tar.gz", hash = "sha256:a4ec1aff59b95a14b45eb2e23761a0179e98319da5a7eb76b56ea8cdc7b871c3", size = 13416, upload-time = "2014-05-08T14:58:53.945Z" } [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] name = "more-itertools" version = "10.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/a0/834b0cebabbfc7e311f30b46c8188790a37f89fc8d756660346fe5abfd09/more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3", size = 127671 } +sdist = { url = "https://files.pythonhosted.org/packages/ce/a0/834b0cebabbfc7e311f30b46c8188790a37f89fc8d756660346fe5abfd09/more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3", size = 127671, upload-time = "2025-04-22T14:17:41.838Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278 }, + { url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278, upload-time = "2025-04-22T14:17:40.49Z" }, ] [[package]] name = "nh3" version = "0.2.21" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/30/2f81466f250eb7f591d4d193930df661c8c23e9056bdc78e365b646054d8/nh3-0.2.21.tar.gz", hash = "sha256:4990e7ee6a55490dbf00d61a6f476c9a3258e31e711e13713b2ea7d6616f670e", size = 16581 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/81/b83775687fcf00e08ade6d4605f0be9c4584cb44c4973d9f27b7456a31c9/nh3-0.2.21-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:fcff321bd60c6c5c9cb4ddf2554e22772bb41ebd93ad88171bbbb6f271255286", size = 1297678 }, - { url = "https://files.pythonhosted.org/packages/22/ee/d0ad8fb4b5769f073b2df6807f69a5e57ca9cea504b78809921aef460d20/nh3-0.2.21-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31eedcd7d08b0eae28ba47f43fd33a653b4cdb271d64f1aeda47001618348fde", size = 733774 }, - { url = "https://files.pythonhosted.org/packages/ea/76/b450141e2d384ede43fe53953552f1c6741a499a8c20955ad049555cabc8/nh3-0.2.21-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d426d7be1a2f3d896950fe263332ed1662f6c78525b4520c8e9861f8d7f0d243", size = 760012 }, - { url = "https://files.pythonhosted.org/packages/97/90/1182275db76cd8fbb1f6bf84c770107fafee0cb7da3e66e416bcb9633da2/nh3-0.2.21-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9d67709bc0d7d1f5797b21db26e7a8b3d15d21c9c5f58ccfe48b5328483b685b", size = 923619 }, - { url = "https://files.pythonhosted.org/packages/29/c7/269a7cfbec9693fad8d767c34a755c25ccb8d048fc1dfc7a7d86bc99375c/nh3-0.2.21-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:55823c5ea1f6b267a4fad5de39bc0524d49a47783e1fe094bcf9c537a37df251", size = 1000384 }, - { url = "https://files.pythonhosted.org/packages/68/a9/48479dbf5f49ad93f0badd73fbb48b3d769189f04c6c69b0df261978b009/nh3-0.2.21-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:818f2b6df3763e058efa9e69677b5a92f9bc0acff3295af5ed013da544250d5b", size = 918908 }, - { url = "https://files.pythonhosted.org/packages/d7/da/0279c118f8be2dc306e56819880b19a1cf2379472e3b79fc8eab44e267e3/nh3-0.2.21-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b3b5c58161e08549904ac4abd450dacd94ff648916f7c376ae4b2c0652b98ff9", size = 909180 }, - { url = "https://files.pythonhosted.org/packages/26/16/93309693f8abcb1088ae143a9c8dbcece9c8f7fb297d492d3918340c41f1/nh3-0.2.21-cp313-cp313t-win32.whl", hash = "sha256:637d4a10c834e1b7d9548592c7aad760611415fcd5bd346f77fd8a064309ae6d", size = 532747 }, - { url = "https://files.pythonhosted.org/packages/a2/3a/96eb26c56cbb733c0b4a6a907fab8408ddf3ead5d1b065830a8f6a9c3557/nh3-0.2.21-cp313-cp313t-win_amd64.whl", hash = "sha256:713d16686596e556b65e7f8c58328c2df63f1a7abe1277d87625dcbbc012ef82", size = 528908 }, - { url = "https://files.pythonhosted.org/packages/ba/1d/b1ef74121fe325a69601270f276021908392081f4953d50b03cbb38b395f/nh3-0.2.21-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a772dec5b7b7325780922dd904709f0f5f3a79fbf756de5291c01370f6df0967", size = 1316133 }, - { url = "https://files.pythonhosted.org/packages/b8/f2/2c7f79ce6de55b41e7715f7f59b159fd59f6cdb66223c05b42adaee2b645/nh3-0.2.21-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d002b648592bf3033adfd875a48f09b8ecc000abd7f6a8769ed86b6ccc70c759", size = 758328 }, - { url = "https://files.pythonhosted.org/packages/6d/ad/07bd706fcf2b7979c51b83d8b8def28f413b090cf0cb0035ee6b425e9de5/nh3-0.2.21-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2a5174551f95f2836f2ad6a8074560f261cf9740a48437d6151fd2d4d7d617ab", size = 747020 }, - { url = "https://files.pythonhosted.org/packages/75/99/06a6ba0b8a0d79c3d35496f19accc58199a1fb2dce5e711a31be7e2c1426/nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b8d55ea1fc7ae3633d758a92aafa3505cd3cc5a6e40470c9164d54dff6f96d42", size = 944878 }, - { url = "https://files.pythonhosted.org/packages/79/d4/dc76f5dc50018cdaf161d436449181557373869aacf38a826885192fc587/nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ae319f17cd8960d0612f0f0ddff5a90700fa71926ca800e9028e7851ce44a6f", size = 903460 }, - { url = "https://files.pythonhosted.org/packages/cd/c3/d4f8037b2ab02ebf5a2e8637bd54736ed3d0e6a2869e10341f8d9085f00e/nh3-0.2.21-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ca02ac6f27fc80f9894409eb61de2cb20ef0a23740c7e29f9ec827139fa578", size = 839369 }, - { url = "https://files.pythonhosted.org/packages/11/a9/1cd3c6964ec51daed7b01ca4686a5c793581bf4492cbd7274b3f544c9abe/nh3-0.2.21-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5f77e62aed5c4acad635239ac1290404c7e940c81abe561fd2af011ff59f585", size = 739036 }, - { url = "https://files.pythonhosted.org/packages/fd/04/bfb3ff08d17a8a96325010ae6c53ba41de6248e63cdb1b88ef6369a6cdfc/nh3-0.2.21-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:087ffadfdcd497658c3adc797258ce0f06be8a537786a7217649fc1c0c60c293", size = 768712 }, - { url = "https://files.pythonhosted.org/packages/9e/aa/cfc0bf545d668b97d9adea4f8b4598667d2b21b725d83396c343ad12bba7/nh3-0.2.21-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ac7006c3abd097790e611fe4646ecb19a8d7f2184b882f6093293b8d9b887431", size = 930559 }, - { url = "https://files.pythonhosted.org/packages/78/9d/6f5369a801d3a1b02e6a9a097d56bcc2f6ef98cffebf03c4bb3850d8e0f0/nh3-0.2.21-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:6141caabe00bbddc869665b35fc56a478eb774a8c1dfd6fba9fe1dfdf29e6efa", size = 1008591 }, - { url = "https://files.pythonhosted.org/packages/a6/df/01b05299f68c69e480edff608248313cbb5dbd7595c5e048abe8972a57f9/nh3-0.2.21-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:20979783526641c81d2f5bfa6ca5ccca3d1e4472474b162c6256745fbfe31cd1", size = 925670 }, - { url = "https://files.pythonhosted.org/packages/3d/79/bdba276f58d15386a3387fe8d54e980fb47557c915f5448d8c6ac6f7ea9b/nh3-0.2.21-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a7ea28cd49293749d67e4fcf326c554c83ec912cd09cd94aa7ec3ab1921c8283", size = 917093 }, - { url = "https://files.pythonhosted.org/packages/e7/d8/c6f977a5cd4011c914fb58f5ae573b071d736187ccab31bfb1d539f4af9f/nh3-0.2.21-cp38-abi3-win32.whl", hash = "sha256:6c9c30b8b0d291a7c5ab0967ab200598ba33208f754f2f4920e9343bdd88f79a", size = 537623 }, - { url = "https://files.pythonhosted.org/packages/23/fc/8ce756c032c70ae3dd1d48a3552577a325475af2a2f629604b44f571165c/nh3-0.2.21-cp38-abi3-win_amd64.whl", hash = "sha256:bb0014948f04d7976aabae43fcd4cb7f551f9f8ce785a4c9ef66e6c2590f8629", size = 535283 }, +sdist = { url = "https://files.pythonhosted.org/packages/37/30/2f81466f250eb7f591d4d193930df661c8c23e9056bdc78e365b646054d8/nh3-0.2.21.tar.gz", hash = "sha256:4990e7ee6a55490dbf00d61a6f476c9a3258e31e711e13713b2ea7d6616f670e", size = 16581, upload-time = "2025-02-25T13:38:44.619Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/81/b83775687fcf00e08ade6d4605f0be9c4584cb44c4973d9f27b7456a31c9/nh3-0.2.21-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:fcff321bd60c6c5c9cb4ddf2554e22772bb41ebd93ad88171bbbb6f271255286", size = 1297678, upload-time = "2025-02-25T13:37:56.063Z" }, + { url = "https://files.pythonhosted.org/packages/22/ee/d0ad8fb4b5769f073b2df6807f69a5e57ca9cea504b78809921aef460d20/nh3-0.2.21-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31eedcd7d08b0eae28ba47f43fd33a653b4cdb271d64f1aeda47001618348fde", size = 733774, upload-time = "2025-02-25T13:37:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/b450141e2d384ede43fe53953552f1c6741a499a8c20955ad049555cabc8/nh3-0.2.21-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d426d7be1a2f3d896950fe263332ed1662f6c78525b4520c8e9861f8d7f0d243", size = 760012, upload-time = "2025-02-25T13:38:01.017Z" }, + { url = "https://files.pythonhosted.org/packages/97/90/1182275db76cd8fbb1f6bf84c770107fafee0cb7da3e66e416bcb9633da2/nh3-0.2.21-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9d67709bc0d7d1f5797b21db26e7a8b3d15d21c9c5f58ccfe48b5328483b685b", size = 923619, upload-time = "2025-02-25T13:38:02.617Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/269a7cfbec9693fad8d767c34a755c25ccb8d048fc1dfc7a7d86bc99375c/nh3-0.2.21-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:55823c5ea1f6b267a4fad5de39bc0524d49a47783e1fe094bcf9c537a37df251", size = 1000384, upload-time = "2025-02-25T13:38:04.402Z" }, + { url = "https://files.pythonhosted.org/packages/68/a9/48479dbf5f49ad93f0badd73fbb48b3d769189f04c6c69b0df261978b009/nh3-0.2.21-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:818f2b6df3763e058efa9e69677b5a92f9bc0acff3295af5ed013da544250d5b", size = 918908, upload-time = "2025-02-25T13:38:06.693Z" }, + { url = "https://files.pythonhosted.org/packages/d7/da/0279c118f8be2dc306e56819880b19a1cf2379472e3b79fc8eab44e267e3/nh3-0.2.21-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b3b5c58161e08549904ac4abd450dacd94ff648916f7c376ae4b2c0652b98ff9", size = 909180, upload-time = "2025-02-25T13:38:10.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/16/93309693f8abcb1088ae143a9c8dbcece9c8f7fb297d492d3918340c41f1/nh3-0.2.21-cp313-cp313t-win32.whl", hash = "sha256:637d4a10c834e1b7d9548592c7aad760611415fcd5bd346f77fd8a064309ae6d", size = 532747, upload-time = "2025-02-25T13:38:12.548Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3a/96eb26c56cbb733c0b4a6a907fab8408ddf3ead5d1b065830a8f6a9c3557/nh3-0.2.21-cp313-cp313t-win_amd64.whl", hash = "sha256:713d16686596e556b65e7f8c58328c2df63f1a7abe1277d87625dcbbc012ef82", size = 528908, upload-time = "2025-02-25T13:38:14.059Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1d/b1ef74121fe325a69601270f276021908392081f4953d50b03cbb38b395f/nh3-0.2.21-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a772dec5b7b7325780922dd904709f0f5f3a79fbf756de5291c01370f6df0967", size = 1316133, upload-time = "2025-02-25T13:38:16.601Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f2/2c7f79ce6de55b41e7715f7f59b159fd59f6cdb66223c05b42adaee2b645/nh3-0.2.21-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d002b648592bf3033adfd875a48f09b8ecc000abd7f6a8769ed86b6ccc70c759", size = 758328, upload-time = "2025-02-25T13:38:18.972Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ad/07bd706fcf2b7979c51b83d8b8def28f413b090cf0cb0035ee6b425e9de5/nh3-0.2.21-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2a5174551f95f2836f2ad6a8074560f261cf9740a48437d6151fd2d4d7d617ab", size = 747020, upload-time = "2025-02-25T13:38:20.571Z" }, + { url = "https://files.pythonhosted.org/packages/75/99/06a6ba0b8a0d79c3d35496f19accc58199a1fb2dce5e711a31be7e2c1426/nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b8d55ea1fc7ae3633d758a92aafa3505cd3cc5a6e40470c9164d54dff6f96d42", size = 944878, upload-time = "2025-02-25T13:38:22.204Z" }, + { url = "https://files.pythonhosted.org/packages/79/d4/dc76f5dc50018cdaf161d436449181557373869aacf38a826885192fc587/nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ae319f17cd8960d0612f0f0ddff5a90700fa71926ca800e9028e7851ce44a6f", size = 903460, upload-time = "2025-02-25T13:38:25.951Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c3/d4f8037b2ab02ebf5a2e8637bd54736ed3d0e6a2869e10341f8d9085f00e/nh3-0.2.21-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ca02ac6f27fc80f9894409eb61de2cb20ef0a23740c7e29f9ec827139fa578", size = 839369, upload-time = "2025-02-25T13:38:28.174Z" }, + { url = "https://files.pythonhosted.org/packages/11/a9/1cd3c6964ec51daed7b01ca4686a5c793581bf4492cbd7274b3f544c9abe/nh3-0.2.21-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5f77e62aed5c4acad635239ac1290404c7e940c81abe561fd2af011ff59f585", size = 739036, upload-time = "2025-02-25T13:38:30.539Z" }, + { url = "https://files.pythonhosted.org/packages/fd/04/bfb3ff08d17a8a96325010ae6c53ba41de6248e63cdb1b88ef6369a6cdfc/nh3-0.2.21-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:087ffadfdcd497658c3adc797258ce0f06be8a537786a7217649fc1c0c60c293", size = 768712, upload-time = "2025-02-25T13:38:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/9e/aa/cfc0bf545d668b97d9adea4f8b4598667d2b21b725d83396c343ad12bba7/nh3-0.2.21-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ac7006c3abd097790e611fe4646ecb19a8d7f2184b882f6093293b8d9b887431", size = 930559, upload-time = "2025-02-25T13:38:35.204Z" }, + { url = "https://files.pythonhosted.org/packages/78/9d/6f5369a801d3a1b02e6a9a097d56bcc2f6ef98cffebf03c4bb3850d8e0f0/nh3-0.2.21-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:6141caabe00bbddc869665b35fc56a478eb774a8c1dfd6fba9fe1dfdf29e6efa", size = 1008591, upload-time = "2025-02-25T13:38:37.099Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/01b05299f68c69e480edff608248313cbb5dbd7595c5e048abe8972a57f9/nh3-0.2.21-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:20979783526641c81d2f5bfa6ca5ccca3d1e4472474b162c6256745fbfe31cd1", size = 925670, upload-time = "2025-02-25T13:38:38.696Z" }, + { url = "https://files.pythonhosted.org/packages/3d/79/bdba276f58d15386a3387fe8d54e980fb47557c915f5448d8c6ac6f7ea9b/nh3-0.2.21-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a7ea28cd49293749d67e4fcf326c554c83ec912cd09cd94aa7ec3ab1921c8283", size = 917093, upload-time = "2025-02-25T13:38:40.249Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d8/c6f977a5cd4011c914fb58f5ae573b071d736187ccab31bfb1d539f4af9f/nh3-0.2.21-cp38-abi3-win32.whl", hash = "sha256:6c9c30b8b0d291a7c5ab0967ab200598ba33208f754f2f4920e9343bdd88f79a", size = 537623, upload-time = "2025-02-25T13:38:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/23/fc/8ce756c032c70ae3dd1d48a3552577a325475af2a2f629604b44f571165c/nh3-0.2.21-cp38-abi3-win_amd64.whl", hash = "sha256:bb0014948f04d7976aabae43fcd4cb7f551f9f8ce785a4c9ef66e6c2590f8629", size = 535283, upload-time = "2025-02-25T13:38:43.355Z" }, ] [[package]] name = "nodeenv" version = "1.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] [[package]] name = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] name = "parse" version = "1.20.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4f/78/d9b09ba24bb36ef8b83b71be547e118d46214735b6dfb39e4bfde0e9b9dd/parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce", size = 29391 } +sdist = { url = "https://files.pythonhosted.org/packages/4f/78/d9b09ba24bb36ef8b83b71be547e118d46214735b6dfb39e4bfde0e9b9dd/parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce", size = 29391, upload-time = "2024-06-11T04:41:57.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/31/ba45bf0b2aa7898d81cbbfac0e88c267befb59ad91a19e36e1bc5578ddb1/parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558", size = 20126 }, + { url = "https://files.pythonhosted.org/packages/d0/31/ba45bf0b2aa7898d81cbbfac0e88c267befb59ad91a19e36e1bc5578ddb1/parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558", size = 20126, upload-time = "2024-06-11T04:41:55.057Z" }, ] [[package]] @@ -627,54 +626,54 @@ dependencies = [ { name = "parse" }, { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/e9/a3b2ae5f8a852542788ac1f1865dcea0c549cc40af243f42cabfa0acf24d/parse_type-0.6.4.tar.gz", hash = "sha256:5e1ec10440b000c3f818006033372939e693a9ec0176f446d9303e4db88489a6", size = 96480 } +sdist = { url = "https://files.pythonhosted.org/packages/17/e9/a3b2ae5f8a852542788ac1f1865dcea0c549cc40af243f42cabfa0acf24d/parse_type-0.6.4.tar.gz", hash = "sha256:5e1ec10440b000c3f818006033372939e693a9ec0176f446d9303e4db88489a6", size = 96480, upload-time = "2024-10-03T11:51:00.353Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/b3/f6cc950042bfdbe98672e7c834d930f85920fb7d3359f59096e8d2799617/parse_type-0.6.4-py2.py3-none-any.whl", hash = "sha256:83d41144a82d6b8541127bf212dd76c7f01baff680b498ce8a4d052a7a5bce4c", size = 27442 }, + { url = "https://files.pythonhosted.org/packages/d5/b3/f6cc950042bfdbe98672e7c834d930f85920fb7d3359f59096e8d2799617/parse_type-0.6.4-py2.py3-none-any.whl", hash = "sha256:83d41144a82d6b8541127bf212dd76c7f01baff680b498ce8a4d052a7a5bce4c", size = 27442, upload-time = "2024-10-03T11:50:58.519Z" }, ] [[package]] name = "platformdirs" version = "4.3.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362 } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567 }, + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "pycparser" version = "2.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, ] [[package]] name = "pygments" version = "2.19.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] [[package]] name = "pyparsing" version = "3.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608 } +sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608, upload-time = "2025-03-25T05:01:28.114Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120 }, + { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120, upload-time = "2025-03-25T05:01:24.908Z" }, ] [[package]] @@ -685,9 +684,9 @@ dependencies = [ { name = "packaging" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/fd/437901c891f58a7b9096511750247535e891d2d5a5a6eefbc9386a2b41d5/pyproject_api-1.9.1.tar.gz", hash = "sha256:43c9918f49daab37e302038fc1aed54a8c7a91a9fa935d00b9a485f37e0f5335", size = 22710 } +sdist = { url = "https://files.pythonhosted.org/packages/19/fd/437901c891f58a7b9096511750247535e891d2d5a5a6eefbc9386a2b41d5/pyproject_api-1.9.1.tar.gz", hash = "sha256:43c9918f49daab37e302038fc1aed54a8c7a91a9fa935d00b9a485f37e0f5335", size = 22710, upload-time = "2025-05-12T14:41:58.025Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/e6/c293c06695d4a3ab0260ef124a74ebadba5f4c511ce3a4259e976902c00b/pyproject_api-1.9.1-py3-none-any.whl", hash = "sha256:7d6238d92f8962773dd75b5f0c4a6a27cce092a14b623b811dba656f3b628948", size = 13158 }, + { url = "https://files.pythonhosted.org/packages/ef/e6/c293c06695d4a3ab0260ef124a74ebadba5f4c511ce3a4259e976902c00b/pyproject_api-1.9.1-py3-none-any.whl", hash = "sha256:7d6238d92f8962773dd75b5f0c4a6a27cce092a14b623b811dba656f3b628948", size = 13158, upload-time = "2025-05-12T14:41:56.217Z" }, ] [[package]] @@ -698,9 +697,9 @@ dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/9a/7ab2b333b921b2d6bfcffe05a0e0a0bbeff884bd6fb5ed50cd68e2898e53/pyright-1.1.401.tar.gz", hash = "sha256:788a82b6611fa5e34a326a921d86d898768cddf59edde8e93e56087d277cc6f1", size = 3894193 } +sdist = { url = "https://files.pythonhosted.org/packages/79/9a/7ab2b333b921b2d6bfcffe05a0e0a0bbeff884bd6fb5ed50cd68e2898e53/pyright-1.1.401.tar.gz", hash = "sha256:788a82b6611fa5e34a326a921d86d898768cddf59edde8e93e56087d277cc6f1", size = 3894193, upload-time = "2025-05-21T10:44:52.03Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/e6/1f908fce68b0401d41580e0f9acc4c3d1b248adcff00dfaad75cd21a1370/pyright-1.1.401-py3-none-any.whl", hash = "sha256:6fde30492ba5b0d7667c16ecaf6c699fab8d7a1263f6a18549e0b00bf7724c06", size = 5629193 }, + { url = "https://files.pythonhosted.org/packages/0d/e6/1f908fce68b0401d41580e0f9acc4c3d1b248adcff00dfaad75cd21a1370/pyright-1.1.401-py3-none-any.whl", hash = "sha256:6fde30492ba5b0d7667c16ecaf6c699fab8d7a1263f6a18549e0b00bf7724c06", size = 5629193, upload-time = "2025-05-21T10:44:50.129Z" }, ] [[package]] @@ -716,15 +715,16 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", size = 1515232 } +sdist = { url = "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", size = 1515232, upload-time = "2025-06-02T17:36:30.03Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797 }, + { url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797, upload-time = "2025-06-02T17:36:27.859Z" }, ] [[package]] name = "python-docx" source = { editable = "." } dependencies = [ + { name = "defusedxml" }, { name = "lxml" }, { name = "typing-extensions" }, ] @@ -747,7 +747,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "lxml", specifier = ">=3.1.0" }, + { name = "defusedxml", specifier = ">=0.7.0" }, + { name = "lxml", specifier = ">=4.9.1" }, { name = "typing-extensions", specifier = ">=4.9.0" }, ] @@ -771,9 +772,9 @@ dev = [ name = "pywin32-ctypes" version = "0.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471 } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, ] [[package]] @@ -785,9 +786,9 @@ dependencies = [ { name = "nh3" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/b5/536c775084d239df6345dccf9b043419c7e3308bc31be4c7882196abc62e/readme_renderer-43.0.tar.gz", hash = "sha256:1818dd28140813509eeed8d62687f7cd4f7bad90d4db586001c5dc09d4fde311", size = 31768 } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b5/536c775084d239df6345dccf9b043419c7e3308bc31be4c7882196abc62e/readme_renderer-43.0.tar.gz", hash = "sha256:1818dd28140813509eeed8d62687f7cd4f7bad90d4db586001c5dc09d4fde311", size = 31768, upload-time = "2024-02-26T16:10:59.415Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/be/3ea20dc38b9db08387cf97997a85a7d51527ea2057d71118feb0aa8afa55/readme_renderer-43.0-py3-none-any.whl", hash = "sha256:19db308d86ecd60e5affa3b2a98f017af384678c63c88e5d4556a380e674f3f9", size = 13301 }, + { url = "https://files.pythonhosted.org/packages/45/be/3ea20dc38b9db08387cf97997a85a7d51527ea2057d71118feb0aa8afa55/readme_renderer-43.0-py3-none-any.whl", hash = "sha256:19db308d86ecd60e5affa3b2a98f017af384678c63c88e5d4556a380e674f3f9", size = 13301, upload-time = "2024-02-26T16:10:57.945Z" }, ] [[package]] @@ -800,9 +801,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258 } +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847 }, + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" }, ] [[package]] @@ -812,18 +813,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888 } +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 }, + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] [[package]] name = "rfc3986" version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026 } +sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326 }, + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" }, ] [[package]] @@ -835,34 +836,34 @@ dependencies = [ { name = "pygments" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 }, + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" }, ] [[package]] name = "ruff" version = "0.11.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054 } +sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516 }, - { url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083 }, - { url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024 }, - { url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324 }, - { url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416 }, - { url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197 }, - { url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615 }, - { url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080 }, - { url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315 }, - { url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640 }, - { url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364 }, - { url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462 }, - { url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028 }, - { url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992 }, - { url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944 }, - { url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669 }, - { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928 }, + { url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" }, + { url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" }, + { url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" }, + { url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" }, + { url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" }, + { url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, ] [[package]] @@ -873,45 +874,45 @@ dependencies = [ { name = "cryptography" }, { name = "jeepney" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739 } +sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739, upload-time = "2022-08-13T16:22:46.976Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221 }, + { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221, upload-time = "2022-08-13T16:22:44.457Z" }, ] [[package]] name = "setuptools" version = "80.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958 } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486 }, + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "snowballstemmer" version = "3.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575 } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274 }, + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, ] [[package]] name = "soupsieve" version = "2.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418 } +sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418, upload-time = "2025-04-20T18:50:08.518Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677 }, + { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload-time = "2025-04-20T18:50:07.196Z" }, ] [[package]] @@ -933,18 +934,18 @@ dependencies = [ { name = "snowballstemmer" }, { name = "sphinxcontrib-websupport" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/74/5cef400220b2f22a4c85540b9ba20234525571b8b851be8a9ac219326a11/Sphinx-1.8.6.tar.gz", hash = "sha256:e096b1b369dbb0fcb95a31ba8c9e1ae98c588e601f08eada032248e1696de4b1", size = 5816141 } +sdist = { url = "https://files.pythonhosted.org/packages/95/74/5cef400220b2f22a4c85540b9ba20234525571b8b851be8a9ac219326a11/Sphinx-1.8.6.tar.gz", hash = "sha256:e096b1b369dbb0fcb95a31ba8c9e1ae98c588e601f08eada032248e1696de4b1", size = 5816141, upload-time = "2021-11-17T15:40:44.891Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/da/e1b65da61267aeb92a76b6b6752430bcc076d98b723687929eb3d2e0d128/Sphinx-1.8.6-py2.py3-none-any.whl", hash = "sha256:5973adbb19a5de30e15ab394ec8bc05700317fa83f122c349dd01804d983720f", size = 3110177 }, + { url = "https://files.pythonhosted.org/packages/c7/da/e1b65da61267aeb92a76b6b6752430bcc076d98b723687929eb3d2e0d128/Sphinx-1.8.6-py2.py3-none-any.whl", hash = "sha256:5973adbb19a5de30e15ab394ec8bc05700317fa83f122c349dd01804d983720f", size = 3110177, upload-time = "2021-11-17T15:40:38.576Z" }, ] [[package]] name = "sphinxcontrib-serializinghtml" version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080 } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072 }, + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] [[package]] @@ -954,48 +955,48 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinxcontrib-serializinghtml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/aa/b03a3f569a52b6f21a579d168083a27036c1f606269e34abdf5b70fe3a2c/sphinxcontrib-websupport-1.2.4.tar.gz", hash = "sha256:4edf0223a0685a7c485ae5a156b6f529ba1ee481a1417817935b20bde1956232", size = 602360 } +sdist = { url = "https://files.pythonhosted.org/packages/da/aa/b03a3f569a52b6f21a579d168083a27036c1f606269e34abdf5b70fe3a2c/sphinxcontrib-websupport-1.2.4.tar.gz", hash = "sha256:4edf0223a0685a7c485ae5a156b6f529ba1ee481a1417817935b20bde1956232", size = 602360, upload-time = "2020-08-09T15:35:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/e5/2a547830845e6e6e5d97b3246fc1e3ec74cba879c9adc5a8e27f1291bca3/sphinxcontrib_websupport-1.2.4-py2.py3-none-any.whl", hash = "sha256:6fc9287dfc823fe9aa432463edd6cea47fa9ebbf488d7f289b322ffcfca075c7", size = 39924 }, + { url = "https://files.pythonhosted.org/packages/e9/e5/2a547830845e6e6e5d97b3246fc1e3ec74cba879c9adc5a8e27f1291bca3/sphinxcontrib_websupport-1.2.4-py2.py3-none-any.whl", hash = "sha256:6fc9287dfc823fe9aa432463edd6cea47fa9ebbf488d7f289b322ffcfca075c7", size = 39924, upload-time = "2020-08-09T15:35:26.141Z" }, ] [[package]] name = "tomli" version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, - { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, - { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, - { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, - { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, - { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, - { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, - { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, - { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, - { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, - { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, - { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, - { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, - { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, - { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, - { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, - { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, - { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, - { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, - { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, - { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, - { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, - { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, - { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, - { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, - { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, - { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, - { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, - { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, - { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, - { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] [[package]] @@ -1015,9 +1016,9 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/dcec0c00321a107f7f697fd00754c5112572ea6dcacb40b16d8c3eea7c37/tox-4.26.0.tar.gz", hash = "sha256:a83b3b67b0159fa58e44e646505079e35a43317a62d2ae94725e0586266faeca", size = 197260 } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/dcec0c00321a107f7f697fd00754c5112572ea6dcacb40b16d8c3eea7c37/tox-4.26.0.tar.gz", hash = "sha256:a83b3b67b0159fa58e44e646505079e35a43317a62d2ae94725e0586266faeca", size = 197260, upload-time = "2025-05-13T15:04:28.481Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/14/f58b4087cf248b18c795b5c838c7a8d1428dfb07cb468dad3ec7f54041ab/tox-4.26.0-py3-none-any.whl", hash = "sha256:75f17aaf09face9b97bd41645028d9f722301e912be8b4c65a3f938024560224", size = 172761 }, + { url = "https://files.pythonhosted.org/packages/de/14/f58b4087cf248b18c795b5c838c7a8d1428dfb07cb468dad3ec7f54041ab/tox-4.26.0-py3-none-any.whl", hash = "sha256:75f17aaf09face9b97bd41645028d9f722301e912be8b4c65a3f938024560224", size = 172761, upload-time = "2025-05-13T15:04:26.207Z" }, ] [[package]] @@ -1036,18 +1037,18 @@ dependencies = [ { name = "rich" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c8/a2/6df94fc5c8e2170d21d7134a565c3a8fb84f9797c1dd65a5976aaf714418/twine-6.1.0.tar.gz", hash = "sha256:be324f6272eff91d07ee93f251edf232fc647935dd585ac003539b42404a8dbd", size = 168404 } +sdist = { url = "https://files.pythonhosted.org/packages/c8/a2/6df94fc5c8e2170d21d7134a565c3a8fb84f9797c1dd65a5976aaf714418/twine-6.1.0.tar.gz", hash = "sha256:be324f6272eff91d07ee93f251edf232fc647935dd585ac003539b42404a8dbd", size = 168404, upload-time = "2025-01-21T18:45:26.758Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/b6/74e927715a285743351233f33ea3c684528a0d374d2e43ff9ce9585b73fe/twine-6.1.0-py3-none-any.whl", hash = "sha256:a47f973caf122930bf0fbbf17f80b83bc1602c9ce393c7845f289a3001dc5384", size = 40791 }, + { url = "https://files.pythonhosted.org/packages/7c/b6/74e927715a285743351233f33ea3c684528a0d374d2e43ff9ce9585b73fe/twine-6.1.0-py3-none-any.whl", hash = "sha256:a47f973caf122930bf0fbbf17f80b83bc1602c9ce393c7845f289a3001dc5384", size = 40791, upload-time = "2025-01-21T18:45:24.584Z" }, ] [[package]] name = "types-html5lib" version = "1.1.11.20250516" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/ed/9f092ff479e2b5598941855f314a22953bb04b5fb38bcba3f880feb833ba/types_html5lib-1.1.11.20250516.tar.gz", hash = "sha256:65043a6718c97f7d52567cc0cdf41efbfc33b1f92c6c0c5e19f60a7ec69ae720", size = 16136 } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ed/9f092ff479e2b5598941855f314a22953bb04b5fb38bcba3f880feb833ba/types_html5lib-1.1.11.20250516.tar.gz", hash = "sha256:65043a6718c97f7d52567cc0cdf41efbfc33b1f92c6c0c5e19f60a7ec69ae720", size = 16136, upload-time = "2025-05-16T03:07:12.231Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/3b/cb5b23c7b51bf48b8c9f175abb9dce2f1ecd2d2c25f92ea9f4e3720e9398/types_html5lib-1.1.11.20250516-py3-none-any.whl", hash = "sha256:5e407b14b1bd2b9b1107cbd1e2e19d4a0c46d60febd231c7ab7313d7405663c1", size = 21770 }, + { url = "https://files.pythonhosted.org/packages/cc/3b/cb5b23c7b51bf48b8c9f175abb9dce2f1ecd2d2c25f92ea9f4e3720e9398/types_html5lib-1.1.11.20250516-py3-none-any.whl", hash = "sha256:5e407b14b1bd2b9b1107cbd1e2e19d4a0c46d60febd231c7ab7313d7405663c1", size = 21770, upload-time = "2025-05-16T03:07:11.102Z" }, ] [[package]] @@ -1060,27 +1061,27 @@ dependencies = [ { name = "types-html5lib" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b7/3a/7f6d1d3b921404efef20ed1ddc2b6f1333e3f0bc5b91da37874e786ff835/types_lxml_multi_subclass-2025.3.30.tar.gz", hash = "sha256:7ac7a78e592fdba16951668968b21511adda49bbefbc0f130e55501b70e068b4", size = 153188 } +sdist = { url = "https://files.pythonhosted.org/packages/b7/3a/7f6d1d3b921404efef20ed1ddc2b6f1333e3f0bc5b91da37874e786ff835/types_lxml_multi_subclass-2025.3.30.tar.gz", hash = "sha256:7ac7a78e592fdba16951668968b21511adda49bbefbc0f130e55501b70e068b4", size = 153188, upload-time = "2025-03-29T22:29:03.374Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/8e/106b4c5a67e6d52475ef51008e6c27d4ad472690d619dc32e079d28a540b/types_lxml_multi_subclass-2025.3.30-py3-none-any.whl", hash = "sha256:b0563e4e49e66eb8093c44e74b262c59e3be6d3bb3437511e3a4843fd74044d1", size = 93475 }, + { url = "https://files.pythonhosted.org/packages/cf/8e/106b4c5a67e6d52475ef51008e6c27d4ad472690d619dc32e079d28a540b/types_lxml_multi_subclass-2025.3.30-py3-none-any.whl", hash = "sha256:b0563e4e49e66eb8093c44e74b262c59e3be6d3bb3437511e3a4843fd74044d1", size = 93475, upload-time = "2025-03-29T22:29:01.756Z" }, ] [[package]] name = "typing-extensions" version = "4.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423 } +sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839 }, + { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, ] [[package]] name = "urllib3" version = "2.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672 } +sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680 }, + { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, ] [[package]] @@ -1092,16 +1093,16 @@ dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316 } +sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c3a104fd0c495184c4f2336d65baf398e3c75d72ea94/virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af", size = 6076316, upload-time = "2025-05-08T17:58:23.811Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982 }, + { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982, upload-time = "2025-05-08T17:58:21.15Z" }, ] [[package]] name = "zipp" version = "3.23.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547 } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276 }, + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, ]