diff --git a/.gitignore b/.gitignore index 3a6fee4..d1f3c83 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,8 @@ __pycache__/ .idea/ *.swp *.swo + +# Serena AI Assistant +.serena/ + +.plataform-integrations/ \ No newline at end of file diff --git a/README.md b/README.md index 8675a33..5504f70 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,10 @@ The Nitro Platform API provides programmatic access to document processing capab #### Using Postman 1. Import the collection from `postman/Platform-API.postman_collection.json` -2. Configure environment variables (see `postman/README.md`) -3. Get a bearer token and run sample requests +2. Configure collection variables (see `postman/README.md`) +3. Get a bearer token +4. Select test files for each request +5. Run the sample requests #### Using Power Automate 1. Create a custom connector using instructions in `power-automate/` diff --git a/postman/README.md b/postman/README.md index 41c6806..7048eac 100644 --- a/postman/README.md +++ b/postman/README.md @@ -11,15 +11,18 @@ This folder contains the Postman collection for the Nitro Platform API with exam 3. Select `Platform-API.postman_collection.json` 4. The collection will appear in your workspace -### 2. Configure Environment Variables - -Create a new environment in Postman with these variables: - -| Postman Variable | Python .env Variable | Description | Example Value | -|------------------|---------------------|-------------|---------------| -| `baseUrl` | `PLATFORM_BASE_URL` | API base URL | `https://api.gonitro.dev` | -| `clientID` | `PLATFORM_CLIENT_ID` | Your client ID | `your-client-id` | -| `clientSecret` | `PLATFORM_CLIENT_SECRET` | Your client secret | `your-client-secret` | +### 2. Configure Collection Variables + +1. Click on the collection name in the sidebar +2. Go to the **Variables** tab +3. Set the **CURRENT VALUE** for these variables: + +| Variable | Description | Example Value | +|----------|-------------|---------------| +| `baseUrl` | API base URL | `https://api.gonitro.dev` | +| `clientID` | Your client ID | `your-client-id` | +| `clientSecret` | Your client secret | `your-client-secret` | +| `repoPath` | Absolute path to this repo | `/Users/you/github/nitro-platform-integrations` | | `token` | (auto-populated) | Bearer token | `eyJ0eXAiOiJKV1Q...` | **Note**: If you're also using the Python samples, you can use the same credential values from your `.env` file - just use the Postman variable names shown above. @@ -41,7 +44,16 @@ The collection is organized into folders: - **Transformations**: Modify PDFs (compress, merge, split, etc.) - **Jobs**: Check status and get results for async operations -## Example Workflow +To run them: +1. Select any request (e.g., **Word → PDF**) +2. Go to **Body** tab → **form-data** +3. **Manually select the file** using the file picker: + - Click on the file field + - Navigate to your `repoPath` folder + - Select the appropriate file from the [File Reference Guide](#file-reference-guide) below +4. Click **Send** + +### Example Workflow 1. **Get Token**: Run "Get Bearer Token" request 2. **Convert Document**: Use "Word → PDF" with a sample .docx file @@ -78,3 +90,62 @@ To get your API credentials: 2. Navigate to **Settings** → **API** 3. Click **Create Application** 4. Name your application and save the Client ID and Client Secret + +## Test Files Reference + +When testing endpoints in Postman, use these test files for each operation: + +### Conversions + +| Endpoint | Test File | Location | +|----------|-----------|----------| +| Word → PDF | Analysis.docx | `test_files/test-batch/Analysis.docx` | +| Excel → PDF | Feedback.xlsx | `test_files/test-batch/Feedback.xlsx` | +| PowerPoint → PDF | SamplePPTX.pptx | `test_files/SamplePPTX.pptx` | +| Image → PDF | GoNitro.png | `test_files/GoNitro.png` | +| PDF → Word | SampleResume.pdf | `test_files/test-pdfs/SampleResume.pdf` | +| PDF → Excel | Sample Tables.pdf | `test_files/test-pdfs/Sample Tables.pdf` | +| PDF → Image | SampleResume.pdf | `test_files/test-pdfs/SampleResume.pdf` | + +### Extractions + +| Endpoint | Test File | Location | +|----------|-----------|----------| +| Extract PDF Text | SampleResume.pdf | `test_files/test-pdfs/SampleResume.pdf` | +| Extract PDF Form Data | BOB - Student-Loan-Application-Form.pdf | `test_files/test-pdfs/BOB - Student-Loan-Application-Form.pdf` | +| Extract PDF Table Data | Sample Tables.pdf | `test_files/test-pdfs/Sample Tables.pdf` | +| Extract and Autodetect Bounding Boxes for PII | SampleResume.pdf | `test_files/test-pdfs/SampleResume.pdf` | +| Extract Bounding Boxes for Strings | SampleResume.pdf | `test_files/test-pdfs/SampleResume.pdf` | +| Get PDF Properties | SampleResume.pdf | `test_files/test-pdfs/SampleResume.pdf` | +| Set PDF Properties | SampleResume.pdf | `test_files/test-pdfs/SampleResume.pdf` | + +### Transformations + +| Endpoint | Test File | Location | +|----------|-----------|----------| +| Redact Bounding boxes (scrub PII) | SampleResume.pdf | `test_files/test-pdfs/SampleResume.pdf` | +| Compress | BOB - Student-Loan-Application-Form.pdf | `test_files/test-pdfs/BOB - Student-Loan-Application-Form.pdf` | +| Flatten | Sample Tables.pdf | `test_files/test-pdfs/Sample Tables.pdf` | +| Rotate Pages | SampleResume.pdf | `test_files/test-pdfs/SampleResume.pdf` | +| Delete Pages | SampleResume.pdf | `test_files/test-pdfs/SampleResume.pdf` | +| Split | SampleResume.pdf | `test_files/test-pdfs/SampleResume.pdf` | +| Merge | Sample Tables.pdf + SampleResume.pdf | `test_files/test-pdfs/` (multiple files) | +| Password Protect | SampleResume.pdf | `test_files/test-pdfs/SampleResume.pdf` | +| Password Remove | PDF-withpassword.pdf | `test_files/PDF-withpassword.pdf` | + +### Available Test Files + +All test files are located in the `test_files/` directory: +``` +test_files/ +├── test-pdfs/ +│ ├── SampleResume.pdf # Resume with text and PII +│ ├── Sample Tables.pdf # PDF with table data +│ └── BOB - Student-Loan-Application-Form.pdf # PDF with form fields +├── test-batch/ +│ ├── Analysis.docx # Word document +│ └── Feedback.xlsx # Excel spreadsheet +├── SamplePPTX.pptx # PowerPoint presentation +├── GoNitro.png # Sample image +└── PDF-withpassword.pdf # Password-protected PDF +``` \ No newline at end of file diff --git a/postman/Sign-API.postman_collection.json b/postman/Sign-API.postman_collection.json new file mode 100755 index 0000000..3598002 --- /dev/null +++ b/postman/Sign-API.postman_collection.json @@ -0,0 +1,604 @@ +{ + "info": { + "_postman_id": "79ced7f0-a852-414f-ac60-0c762a02263b", + "name": "Nitro Collection", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "23623766" + }, + "item": [ + { + "name": "Envelope", + "item": [ + { + "name": "Document", + "item": [ + { + "name": "List Documents by Envelope ID", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{baseURL}}/sign/envelopes//documents", + "host": [ + "{{baseURL}}" + ], + "path": [ + "sign", + "envelopes", + "", + "documents" + ] + } + }, + "response": [] + }, + { + "name": "Get Document by ID", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{baseURL}}/sign/envelopes//documents/", + "host": [ + "{{baseURL}}" + ], + "path": [ + "sign", + "envelopes", + "", + "documents", + "" + ] + } + }, + "response": [] + }, + { + "name": "Delete Document by ID", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{baseURL}}/sign/envelopes//documents/", + "host": [ + "{{baseURL}}" + ], + "path": [ + "sign", + "envelopes", + "", + "documents", + "" + ] + } + }, + "response": [] + }, + { + "name": "Create Document by Envelope ID", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "metadata", + "value": "{\n \"name\": \"Sample doc\"\n}", + "type": "text" + }, + { + "key": "payload", + "type": "file", + "src": "example-file.pdf" + } + ] + }, + "url": { + "raw": "{{baseURL}}/sign/envelopes//documents", + "host": [ + "{{baseURL}}" + ], + "path": [ + "sign", + "envelopes", + "", + "documents" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Fields", + "item": [ + { + "name": "Get Document by ID Copy", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"boundingBox\": [0, 0, 200, 40],\n \"participantID\": \"/documents//fields", + "host": [ + "{{baseURL}}" + ], + "path": [ + "sign", + "envelopes", + "", + "documents", + "", + "fields" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Participant", + "item": [ + { + "name": "Create Participant by Envelope ID", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"role\": \"\",\n \"email\": \"\",\n \"authentication\": {\n \"type\": \"AccessCode\",\n \"accessCode\": \"\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/sign/envelopes//participants", + "host": [ + "{{baseURL}}" + ], + "path": [ + "sign", + "envelopes", + "", + "participants" + ] + } + }, + "response": [] + }, + { + "name": "List Participants by Envelope ID", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{baseURL}}/sign/envelopes//participants", + "host": [ + "{{baseURL}}" + ], + "path": [ + "sign", + "envelopes", + "", + "participants" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "List Envelope", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}", + "type": "text" + } + ], + "url": { + "raw": "{{baseURL}}/sign/envelopes", + "host": [ + "{{baseURL}}" + ], + "path": [ + "sign", + "envelopes" + ], + "query": [ + { + "key": "pageAfter", + "value": "", + "disabled": true + }, + { + "key": "pageBefore", + "value": "", + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "Get Envelope by ID", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "{{baseURL}}/sign/envelopes/", + "host": [ + "{{baseURL}}" + ], + "path": [ + "sign", + "envelopes", + "" + ] + } + }, + "response": [] + }, + { + "name": "Create Envelope", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"\",\n \"mode\": \"\",\n \"notification\": {\n \"subject\": \"\",\n \"body\": \"\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/sign/envelopes", + "host": [ + "{{baseURL}}" + ], + "path": [ + "sign", + "envelopes" + ] + } + }, + "response": [] + }, + { + "name": "Update Envelope", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"\",\n \"mode\": \"\",\n \"notification\": {\n \"subject\": \"\",\n \"body\": \"\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/sign/envelopes/", + "host": [ + "{{baseURL}}" + ], + "path": [ + "sign", + "envelopes", + "" + ] + } + }, + "response": [] + }, + { + "name": "Download Sealed Envelope", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "body": { + "mode": "formdata", + "formdata": [] + }, + "url": { + "raw": "{{baseURL}}/sign/envelopes/:download-sealed", + "host": [ + "{{baseURL}}" + ], + "path": [ + "sign", + "envelopes", + ":download-sealed" + ] + } + }, + "response": [] + }, + { + "name": "Download Original Envelope", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "body": { + "mode": "formdata", + "formdata": [] + }, + "url": { + "raw": "{{baseURL}}/sign/envelopes/:download-original", + "host": [ + "{{baseURL}}" + ], + "path": [ + "sign", + "envelopes", + ":download-original" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Authentication", + "item": [ + { + "name": "Get Access Token", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"cliendID\": \"\",\n \"clientSecret\": \"\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseURL}}/oauth/token", + "host": [ + "{{baseURL}}" + ], + "path": [ + "oauth", + "token" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Conversions", + "item": [ + { + "name": "Get Converted File by Job ID", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "body": { + "mode": "formdata", + "formdata": [] + }, + "url": { + "raw": "{{baseURL}}/sign/conversions/:download-converted", + "host": [ + "{{baseURL}}" + ], + "path": [ + "sign", + "conversions", + ":download-converted" + ] + } + }, + "response": [] + }, + { + "name": "Get Conversion Job Status", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "body": { + "mode": "formdata", + "formdata": [] + }, + "url": { + "raw": "{{baseURL}}/sign/conversions//status", + "host": [ + "{{baseURL}}" + ], + "path": [ + "sign", + "conversions", + "", + "status" + ] + } + }, + "response": [] + }, + { + "name": "Convert File to PDF", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "file", + "type": "file", + "src": "example-file.pdf" + } + ] + }, + "url": { + "raw": "{{baseURL}}/sign/conversions", + "host": [ + "{{baseURL}}" + ], + "path": [ + "sign", + "conversions" + ] + } + }, + "response": [] + } + ] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "baseURL", + "value": "https://api.gonitro.dev" + }, + { + "key": "token", + "value": "" + } + ] +} \ No newline at end of file diff --git a/samples/python/.gitignore b/samples/python/.gitignore index 4ad6dc2..66f64d8 100644 --- a/samples/python/.gitignore +++ b/samples/python/.gitignore @@ -2,8 +2,8 @@ .env # Temporary test files and outputs -temp/ output/ +test_output/ # Python __pycache__/ @@ -11,3 +11,12 @@ __pycache__/ *.pyo *.pyd .Python +*.egg-info/ + +# uv +.venv/ +uv.lock + +# Ruff cache +.ruff_cache/ + diff --git a/samples/python/.python-version b/samples/python/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/samples/python/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/samples/python/README.md b/samples/python/README.md index 688fbb5..d37a260 100644 --- a/samples/python/README.md +++ b/samples/python/README.md @@ -4,84 +4,216 @@ Python examples for integrating with the Nitro Platform API. ## Setup -1. Install dependencies: +### Option 1: Using uv (Recommended) + +This project uses [uv](https://docs.astral.sh/uv/) for fast, reliable Python package management. + +1. Install uv if you haven't already: + ```bash + curl -LsSf https://astral.sh/uv/install.sh | sh + ``` + +2. Sync dependencies: + ```bash + cd samples/python + uv sync + ``` + +3. Copy and configure environment variables: + ```bash + cp .env.example .env + # Edit .env with your credentials + ``` + +4. Run the quickstart example: + ```bash + uv run python quickstart.py + ``` + + Or use the Task command: + ```bash + task quickstart + ``` + +### Option 2: Using pip + +1. Create and activate a virtual environment: + ```bash + python -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +2. Install dependencies: ```bash - pip install -r requirements.txt + pip install -e . # Installs from pyproject.toml ``` -2. Copy and configure environment variables: +3. Copy and configure environment variables: ```bash cp .env.example .env # Edit .env with your credentials ``` -3. Run the quickstart example: +4. Run the quickstart example: ```bash python quickstart.py ``` -## Files +## Development + +### Running Scripts with uv + +All Python scripts should be run using `uv run` to ensure they use the correct dependencies: + +```bash +uv run python script.py +``` + +### Code Quality Tools + +This project uses multiple linting and type checking tools configured in `pyproject.toml`: + +#### Ruff (Fast Python linter) +```bash +# Check for linting issues +uvx ruff check + +# Auto-fix issues +uvx ruff check --fix + +# Format code +uvx ruff format +``` + +#### Pylint (Comprehensive linter) +```bash +# Check all Python files +uv run pylint *.py api/*.py helper_functions/*.py + +# Check specific file +uv run pylint convert_cli.py +``` + +#### Pyright (Type checker) +```bash +# Type check all files +uv run pyright + +# Type check specific file +uv run pyright convert_cli.py +``` + +#### Run All Quality Checks +```bash +# Run all three tools +uvx ruff check && uv run pylint *.py api/*.py helper_functions/*.py && uv run pyright +``` + +## Architecture -- `platform_api.py` - Main API client library +### API Client Structure + +The Python SDK uses a clean, object-oriented architecture: + +``` +api/ +├── __init__.py # Package exports +├── base_client.py # BaseOAuthClient - Shared OAuth2 authentication +├── platform_api.py # PlatformAPIClient - Document operations +└── sign_api.py # SignAPIClient - eSignature operations +``` + +**BaseOAuthClient**: Base class providing OAuth2 authentication for all API clients +- Automatic token management and refresh +- Public `get_token()` method for accessing authentication tokens +- Shared by both Platform and Sign API clients + +**PlatformAPIClient**: Client for document conversions, extractions, and transformations +- Inherits authentication from BaseOAuthClient +- Methods: `convert()`, `extract_text()`, `detect_pii()`, `redact()`, `compress()`, etc. + +**SignAPIClient**: Client for eSignature/envelope operations +- Inherits authentication from BaseOAuthClient +- Methods: `create_envelope()`, `create_participant()`, `send_envelope()`, etc. +- See [SIGN_API.md](SIGN_API.md) for detailed documentation + +## CLI Tools + +### Platform API Tools - `quickstart.py` - Authentication test - `convert_cli.py` - Document conversion -- `extract_data.py` - Extract forms and tables +- `extract_data.py` - Extract forms and tables from PDFs - `smart_redact_pii.py` - Auto-detect and redact PII - `redact_by_keyword.py` - Redact specific keywords - `batch_process.py` - Batch convert documents - `bulk_password_protect.py` - Password protect multiple PDFs +- `prepare_pdf_for_distribution.py` - Prepare PDFs for external distribution (convert, compress, remove metadata) + +### Sign API Tools (eSignature) +- `employee_policy_onboarding.py` - Complete HR workflow: send policy documents to employees for signature ## Usage Examples ### Authentication ```bash -python quickstart.py +# Run directly +uv run python quickstart.py + +# Or use Task command +task quickstart ``` ### Convert Documents ```bash # Convert DOCX to PDF -python convert_cli.py input.docx output.pdf pdf +uv run python convert_cli.py input.docx output.pdf pdf -# Convert PDF to DOCX -python convert_cli.py input.pdf output.docx docx +# Or use Task command +task convert INPUT=input.docx OUTPUT=output.pdf FORMAT=pdf ``` ### Extract Data ```bash # Extract tables -python extract_data.py tables input.pdf output.json +uv run python extract_data.py tables input.pdf output.json -# Extract forms -python extract_data.py forms input.pdf output.json +# Or use Task command +task extract MODE=tables INPUT=input.pdf OUTPUT=output.json ``` ### Redact Content ```bash # Auto-detect and redact PII -python smart_redact_pii.py input.pdf output.pdf +uv run python smart_redact_pii.py input_folder output_folder -# Redact specific keywords -python redact_by_keyword.py input.pdf output.pdf "confidential" "secret" +# Or use Task command +task smart-redact INPUT_DIR=./input OUTPUT_DIR=./output ``` ### Batch Operations ```bash # Convert all DOCX files to PDF -python batch_process.py ./input ./output pdf "*.docx" +uv run python batch_process.py ./input ./output pdf "*.docx" -# Password protect all PDFs -python bulk_password_protect.py ./input ./output "MyPassword123" +# Or use Task command +task batch INPUT_DIR=./input OUTPUT_DIR=./output FORMAT=pdf PATTERN='*.docx' ``` ## Using the API Client +### Platform API Client + ```python from pathlib import Path -from platform_api import PlatformAPIClient +from api.platform_api import PlatformAPIClient +# Initialize client (loads credentials from .env) client = PlatformAPIClient() +# Get authentication token (if needed) +token = client.get_token() +print(f"Access token: {token[:20]}...") + # Convert document converted = client.convert(Path("input.docx"), "pdf") Path("output.pdf").write_bytes(converted) @@ -97,6 +229,93 @@ redactions = [{"pageIndex": 0, "boundingBox": {...}}] redacted = client.redact(Path("document.pdf"), redactions) ``` +### Sign API Client + +```python +from pathlib import Path +from api.sign_api import SignAPIClient + +# Initialize client (loads credentials from .env) +sign_client = SignAPIClient() + +# Create an envelope +envelope_data = { + "name": "Contract Signature", + "mode": "parallel", + "notification": { + "subject": "Please sign the contract", + "body": "Review and sign the attached document." + } +} +envelope = sign_client.create_envelope(envelope_data) +envelope_id = envelope["ID"] + +# Add participant +participant_data = { + "email": "signer@example.com", + "role": "signer", + "name": "John Doe" +} +participant = sign_client.create_participant(envelope_id, participant_data) + +# Send envelope +sign_client.send_envelope(envelope_id) + +print(f"Envelope {envelope_id} sent successfully!") +``` + +### Shared Authentication + +Both clients inherit from `BaseOAuthClient`, so they share the same authentication mechanism: + +```python +from api.platform_api import PlatformAPIClient +from api.sign_api import SignAPIClient + +# Both clients use the same credentials from .env +platform_client = PlatformAPIClient() +sign_client = SignAPIClient() + +# Both can access tokens via the public API +platform_token = platform_client.get_token() +sign_token = sign_client.get_token() +``` + +## Code Quality + +This project maintains high code quality standards: + +- **Type Checking**: Strict type checking with Pyright (0 errors) +- **Linting**: Modern Python patterns with Ruff +- **Code Quality**: Pylint score of 9.81/10 +- **Python Version**: Requires Python 3.14+ + +### Running Linters + +```bash +# Type checking +pyright + +# Modern Python patterns +uv run --with ruff ruff check . + +# Code quality analysis +uv run pylint *.py api/*.py helper_functions/*.py +``` + +## Testing + +A comprehensive test suite is available in `TEST_SUITE.txt` with commands for testing all scripts and functionality. Run tests with: + +```bash +# Individual script tests +python quickstart.py +python convert_cli.py ../../test_files/test-batch/Analysis.docx /tmp/output.pdf pdf + +# Or use the automated test script (see TEST_SUITE.txt) +./test_all.sh +``` + ## Sample Files Sample files for testing are available in the `test_files/` folder at the repository root. diff --git a/samples/python/Taskfile.yml b/samples/python/Taskfile.yml index 3e3fc49..3e34453 100644 --- a/samples/python/Taskfile.yml +++ b/samples/python/Taskfile.yml @@ -1,55 +1,76 @@ version: '3' tasks: + quickstart: + desc: "Test API authentication (first thing to run after setup)" + cmds: + - uv run python quickstart.py + smart-redact: - desc: "Smart redact PII from a document (e.g., task smart-redact INPUT=resume.pdf OUTPUT=redacted.pdf)" + desc: "Smart redact PII from documents in a folder (e.g., task smart-redact INPUT_DIR=./documents OUTPUT_DIR=./redacted)" cmds: - - python smart_redact_pii.py {{.INPUT}} {{.OUTPUT}} + - uv run python smart_redact_pii.py "{{.INPUT_DIR}}" "{{.OUTPUT_DIR}}" requires: - vars: [INPUT, OUTPUT] + vars: [INPUT_DIR, OUTPUT_DIR] convert: desc: "Convert document from CLI (e.g., task convert INPUT=doc.docx OUTPUT=doc.pdf FORMAT=pdf)" cmds: - - python convert_cli.py {{.INPUT}} {{.OUTPUT}} {{.FORMAT}} + - uv run python convert_cli.py {{.INPUT}} {{.OUTPUT}} {{.FORMAT}} requires: vars: [INPUT, OUTPUT, FORMAT] batch: desc: "Batch process documents (e.g., task batch INPUT_DIR=./docs OUTPUT_DIR=./output FORMAT=pdf PATTERN='*.docx')" cmds: - - python batch_process.py "{{.INPUT_DIR}}" "{{.OUTPUT_DIR}}" {{.FORMAT}} "{{.PATTERN | default "*"}}" + - uv run python batch_process.py "{{.INPUT_DIR}}" "{{.OUTPUT_DIR}}" {{.FORMAT}} "{{.PATTERN | default "*"}}" requires: vars: [INPUT_DIR, OUTPUT_DIR, FORMAT] extract: desc: "Extract forms or tables from PDF (e.g., task extract MODE=forms INPUT=form.pdf OUTPUT=data.json)" cmds: - - python extract_data.py {{.MODE}} "{{.INPUT}}" "{{.OUTPUT}}" + - uv run python extract_data.py {{.MODE}} "{{.INPUT}}" "{{.OUTPUT}}" requires: vars: [MODE, INPUT, OUTPUT] password-protect: desc: "Bulk password protect PDFs (e.g., task password-protect INPUT_DIR=./pdfs OUTPUT_DIR=./protected PASSWORD=secret123)" cmds: - - python bulk_password_protect.py "{{.INPUT_DIR}}" "{{.OUTPUT_DIR}}" "{{.PASSWORD}}" + - uv run python bulk_password_protect.py "{{.INPUT_DIR}}" "{{.OUTPUT_DIR}}" "{{.PASSWORD}}" requires: vars: [INPUT_DIR, OUTPUT_DIR, PASSWORD] redact-keyword: - desc: "Redact by keyword search (e.g., task redact-keyword INPUT=doc.pdf OUTPUT=redacted.pdf KEYWORDS='confidential secret')" + desc: "Redact specific keywords from a PDF (e.g., task redact-keyword INPUT=doc.pdf OUTPUT=redacted.pdf KEYWORDS='confidential secret')" cmds: - - python redact_by_keyword.py {{.INPUT}} {{.OUTPUT}} {{.KEYWORDS}} + - uv run python redact_by_keyword.py {{.INPUT}} {{.OUTPUT}} {{.KEYWORDS}} requires: vars: [INPUT, OUTPUT, KEYWORDS] install: - desc: "Install Python dependencies" + desc: "Install Python dependencies using uv" cmds: - - pip install -r requirements.txt + - uv sync setup: desc: "Setup environment (copy .env.example to .env)" cmds: - cp .env.example .env - echo "✅ Created .env file - please update with your credentials" + + prepare-distribution: + desc: "Prepare documents for external distribution (convert, compress, remove metadata) (e.g., task prepare-distribution INPUT_DIR=./brochures OUTPUT_DIR=./ready)" + cmds: + - uv run python prepare_pdf_for_distribution.py "{{.INPUT_DIR}}" "{{.OUTPUT_DIR}}" + requires: + vars: [INPUT_DIR, OUTPUT_DIR] + + # ========== Sign API Tasks (eSignature) ========== + + onboard-employees: + desc: "Send company policies to new employees for signature (e.g., task onboard-employees POLICIES_DIR=./policies CSV=new_hires.csv)" + cmds: + - uv run python employee_policy_onboarding.py "{{.POLICIES_DIR}}" "{{.CSV}}" + requires: + vars: [POLICIES_DIR, CSV] diff --git a/samples/python/api/__init__.py b/samples/python/api/__init__.py new file mode 100644 index 0000000..d88fe23 --- /dev/null +++ b/samples/python/api/__init__.py @@ -0,0 +1,7 @@ +"""API clients for Nitro Platform integrations.""" + +from .base_client import BaseOAuthClient +from .platform_api import PlatformAPIClient +from .sign_api import SignAPIClient + +__all__ = ["BaseOAuthClient", "PlatformAPIClient", "SignAPIClient"] diff --git a/samples/python/api/base_client.py b/samples/python/api/base_client.py new file mode 100644 index 0000000..193159a --- /dev/null +++ b/samples/python/api/base_client.py @@ -0,0 +1,82 @@ +"""Base client for API authentication with OAuth2.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import Protocol + +import httpx +from pydantic import BaseModel, Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +TOKEN_EXPIRY_BUFFER_SECONDS = 60 + + +class TokenResponse(BaseModel): + """OAuth2 token response model.""" + + model_config = {"populate_by_name": True} + + access_token: str = Field(alias="accessToken") + expires_in: int = Field(default=3600, alias="expiresIn") + + +class _SettingsProtocol(Protocol): # pylint: disable=too-few-public-methods + """Protocol defining required settings for OAuth clients.""" + + platform_client_id: str + platform_client_secret: str + platform_base_url: str + + +class Settings(BaseSettings): + """Application settings loaded from environment variables or .env file.""" + + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + + platform_client_id: str + platform_client_secret: str + platform_base_url: str = "https://api.gonitro.dev" + + +@dataclass +class BaseOAuthClient: + """Base class for API clients with OAuth2 authentication.""" + + _settings: _SettingsProtocol = field( + default_factory=lambda: Settings() # type: ignore[reportCallIssue] # pylint: disable=unnecessary-lambda + ) + _token: str | None = field(default=None, init=False) + _token_expiry: float = field(default=0, init=False) + _client: httpx.Client = field(default_factory=httpx.Client, init=False) + + def _get_token(self) -> str: + """Get or refresh OAuth2 access token.""" + if self._token and time.time() < self._token_expiry: + return self._token + + response = self._client.post( + f"{self._settings.platform_base_url}/oauth/token", + json={ + "clientID": self._settings.platform_client_id, + "clientSecret": self._settings.platform_client_secret, + }, + ) + response.raise_for_status() + + # Use Pydantic model for type-safe response parsing + token_data = TokenResponse.model_validate_json(response.content) + + self._token = token_data.access_token + self._token_expiry = time.time() + token_data.expires_in - TOKEN_EXPIRY_BUFFER_SECONDS + return token_data.access_token + + def get_token(self) -> str: + """Public method to get authentication token. + + Returns: + OAuth2 access token for API authentication. + """ + return self._get_token() diff --git a/samples/python/platform_api.py b/samples/python/api/platform_api.py similarity index 52% rename from samples/python/platform_api.py rename to samples/python/api/platform_api.py index 969e253..312dd3e 100644 --- a/samples/python/platform_api.py +++ b/samples/python/api/platform_api.py @@ -1,167 +1,150 @@ #!/usr/bin/env python """Platform API client for Nitro Platform integrations.""" -import os -import time +from __future__ import annotations + import json import mimetypes -import requests -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Literal -from dotenv import load_dotenv +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal + +import httpx + +from .base_client import BaseOAuthClient -load_dotenv() +if TYPE_CHECKING: + from pathlib import Path @dataclass -class PlatformAPIClient: +class PlatformAPIClient(BaseOAuthClient): """Synchronous client for Nitro Platform API operations.""" - - base_url: str = field(default_factory=lambda: os.getenv('PLATFORM_BASE_URL', 'https://api.gonitro.dev')) - client_id: str = field(default_factory=lambda: os.getenv('PLATFORM_CLIENT_ID')) - client_secret: str = field(default_factory=lambda: os.getenv('PLATFORM_CLIENT_SECRET')) - _token: str | None = field(default=None, init=False) - _token_expiry: float = field(default=0, init=False) - - def _get_token(self) -> str: - """Get or refresh OAuth2 access token.""" - if self._token and time.time() < self._token_expiry: - return self._token - - response = requests.post( - f"{self.base_url}/oauth/token", - json={"clientID": self.client_id, "clientSecret": self.client_secret} - ) - response.raise_for_status() - data = response.json() - self._token = data['accessToken'] - self._token_expiry = time.time() + data.get('expiresIn', 3600) - 60 - return self._token - + def _request( self, endpoint: Literal["conversions", "extractions", "transformations"], method: str, file_path: Path, - params: dict[str, Any] = None + params: dict[str, Any] | None = None, ) -> dict[str, Any]: """Make API request with file upload.""" headers = {"Authorization": f"Bearer {self._get_token()}"} - + # Detect MIME type or use octet-stream as fallback - mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream' - + mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream" + # Read file into memory and include content-type - files = {'file': (file_path.name, file_path.read_bytes(), mime_type)} - data = { - 'method': method, - 'params': json.dumps(params or {}) - } - - response = requests.post( - f"{self.base_url}/{endpoint}", - headers=headers, - files=files, - data=data + files = {"file": (file_path.name, file_path.read_bytes(), mime_type)} + data = {"method": method, "params": json.dumps(params or {})} + + response = self._client.post( + f"{self._settings.platform_base_url}/{endpoint}", headers=headers, files=files, data=data ) - + response.raise_for_status() return response.json() - + def _request_bytes( self, endpoint: Literal["conversions", "extractions", "transformations"], method: str, file_path: Path, - params: dict[str, Any] = None + params: dict[str, Any] | None = None, ) -> bytes: """Make API request and return raw bytes.""" headers = {"Authorization": f"Bearer {self._get_token()}"} - + # Detect MIME type or use octet-stream as fallback - mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream' - + mime_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream" + # Read file into memory and include content-type - files = {'file': (file_path.name, file_path.read_bytes(), mime_type)} - data = { - 'method': method, - 'params': json.dumps(params or {}) - } - - response = requests.post( - f"{self.base_url}/{endpoint}", - headers=headers, - files=files, - data=data + files = {"file": (file_path.name, file_path.read_bytes(), mime_type)} + data = {"method": method, "params": json.dumps(params or {})} + + response = self._client.post( + f"{self._settings.platform_base_url}/{endpoint}", headers=headers, files=files, data=data ) - + response.raise_for_status() result = response.json() - + # Download from S3 URL - download_url = result['result']['file']['URL'] - download_response = requests.get(download_url) + download_url = result["result"]["file"]["URL"] + download_response = httpx.get(download_url) download_response.raise_for_status() return download_response.content - + def convert(self, file_path: Path, to_format: str) -> bytes: """Convert document to specified format.""" return self._request_bytes("conversions", "convert", file_path, {"to": to_format}) - + def extract_text(self, file_path: Path) -> dict[str, Any]: """Extract text from document.""" return self._request("extractions", "extract-text", file_path) - + def extract_forms(self, file_path: Path) -> dict[str, Any]: """Extract form data from PDF.""" return self._request("extractions", "extract-forms", file_path) - + def extract_tables(self, file_path: Path) -> dict[str, Any]: """Extract table data from PDF.""" return self._request("extractions", "extract-tables", file_path) - + def detect_pii(self, file_path: Path, language: str = "en") -> dict[str, Any]: """Detect PII and return bounding boxes.""" - return self._request("extractions", "extract-pii-bounding-boxes", file_path, {"language": language}) - + return self._request( + "extractions", "extract-pii-bounding-boxes", file_path, {"language": language} + ) + def find_text_boxes(self, file_path: Path, texts: list[str]) -> dict[str, Any]: """Find bounding boxes for specified text strings.""" - return self._request("extractions", "extract-text-bounding-boxes", file_path, {"texts": texts}) - - def redact(self, file_path: Path, redactions: list[dict]) -> bytes: + return self._request( + "extractions", "extract-text-bounding-boxes", file_path, {"texts": texts} + ) + + def redact(self, file_path: Path, redactions: list[dict[str, Any]]) -> bytes: """Redact specified bounding boxes.""" - return self._request_bytes("transformations", "redact", file_path, {"redactions": redactions}) - + return self._request_bytes( + "transformations", "redact", file_path, {"redactions": redactions} + ) + def password_protect(self, file_path: Path, password: str) -> bytes: """Add password protection to PDF.""" return self._request_bytes( "transformations", "protect", file_path, - {"ownerPassword": password, "userPassword": password} + {"ownerPassword": password, "userPassword": password}, ) - + def compress(self, file_path: Path, level: int = 2) -> bytes: """Compress PDF (level 1-3).""" return self._request_bytes("transformations", "compress", file_path, {"level": level}) - + + def set_properties(self, file_path: Path, properties: dict[str, str]) -> bytes: + """Set or clear PDF metadata properties.""" + return self._request_bytes( + "transformations", "set-properties", file_path, properties + ) + def merge(self, file_paths: list[Path]) -> bytes: """Merge multiple PDFs.""" headers = {"Authorization": f"Bearer {self._get_token()}"} - - files = [('file', open(fp, 'rb')) for fp in file_paths] - data = {'method': 'merge', 'params': '{}'} - + + # Open files with context manager + opened_files = [fp.open("rb") for fp in file_paths] try: - response = requests.post( - f"{self.base_url}/transformations", + files = [("file", (f.name, f)) for f in opened_files] + data = {"method": "merge", "params": "{}"} + + response = self._client.post( + f"{self._settings.platform_base_url}/transformations", headers=headers, files=files, - data=data + data=data, ) response.raise_for_status() return response.content finally: - for _, f in files: + for f in opened_files: f.close() diff --git a/samples/python/api/sign_api.py b/samples/python/api/sign_api.py new file mode 100644 index 0000000..f596222 --- /dev/null +++ b/samples/python/api/sign_api.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python +"""Sign API client for Nitro Sign integrations (eSignature operations).""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import httpx + +from .base_client import BaseOAuthClient + +if TYPE_CHECKING: + from pathlib import Path + + +@dataclass +class SignAPIClient(BaseOAuthClient): + """Synchronous client for Nitro Sign API operations (eSignature/envelopes).""" + + def _request( + self, + method: str, + endpoint: str, + json_data: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Make authenticated API request returning JSON.""" + headers = {"Authorization": f"Bearer {self._get_token()}"} + + response = self._client.request( + method=method, + url=f"{self._settings.platform_base_url}{endpoint}", + headers=headers, + json=json_data, + params=params, + ) + + try: + response.raise_for_status() + except httpx.HTTPError: + # Try to get error details from response + try: + error_detail = response.json() + print(f" ❌ API Error Response: {error_detail}") + except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught + print(f" ❌ API Error (no JSON): {response.text}") + raise + + return response.json() + + def _request_bytes( + self, method: str, endpoint: str, params: dict[str, Any] | None = None + ) -> bytes: + """Make authenticated API request returning binary data.""" + headers = {"Authorization": f"Bearer {self._get_token()}"} + + response = self._client.request( + method=method, + url=f"{self._settings.platform_base_url}{endpoint}", + headers=headers, + params=params, + ) + + response.raise_for_status() + return response.content + + # ========== Envelope Management ========== + + def list_envelopes( + self, page_after: str | None = None, page_before: str | None = None + ) -> dict[str, Any]: + """List all envelopes with cursor-based pagination. + + Args: + page_after: Cursor token to get items after the last item from previous response + page_before: Cursor token to get items before the last item from previous response + + Returns: + Dict with 'items' (list of envelopes) and optional 'nextPage' (cursor token) + """ + params: dict[str, str] = {} + if page_after: + params["pageAfter"] = page_after + elif page_before: + params["pageBefore"] = page_before + + return self._request("GET", "/sign/envelopes", params=params) + + def create_envelope(self, envelope_data: dict[str, Any]) -> dict[str, Any]: + """Create a new envelope. + + Args: + envelope_data: Envelope configuration including name, documents, participants, fields + + Returns: + Created envelope with ID and status + """ + return self._request("POST", "/sign/envelopes", json_data=envelope_data) + + def get_envelope(self, envelope_id: str) -> dict[str, Any]: + """Get envelope details by ID. + + Args: + envelope_id: UUID of the envelope + + Returns: + Envelope details including status, participants, documents + """ + return self._request("GET", f"/sign/envelopes/{envelope_id}") + + def update_envelope(self, envelope_id: str, updates: dict[str, Any]) -> dict[str, Any]: + """Update an envelope. + + Args: + envelope_id: UUID of the envelope + updates: Fields to update (name, participants, etc.) + + Returns: + Updated envelope data + """ + return self._request("PATCH", f"/sign/envelopes/{envelope_id}", json_data=updates) + + def delete_envelope(self, envelope_id: str) -> None: + """Delete an envelope by ID. + + Args: + envelope_id: UUID of the envelope + """ + headers = {"Authorization": f"Bearer {self._get_token()}"} + response = self._client.delete( + f"{self._settings.platform_base_url}/sign/envelopes/{envelope_id}", + headers=headers, + ) + response.raise_for_status() + + # ========== Document Management ========== + + def create_document( + self, envelope_id: str, file_path: Path, document_name: str | None = None + ) -> dict[str, Any]: + """Upload a document to an envelope using form-data. + + Args: + envelope_id: ID of the envelope + file_path: Path to the PDF file to upload + document_name: Optional custom name for the document + + Returns: + Created document with ID + """ + if document_name is None: + document_name = file_path.name + + # Read the binary content of the PDF file + with file_path.open("rb") as f: + pdf_binary = f.read() + + # Prepare metadata as JSON string + metadata = json.dumps({"name": document_name}) + + # Prepare form-data with binary content + files = { + "metadata": ("metadata", metadata, "application/json"), + "payload": (file_path.name, pdf_binary, "application/pdf"), + } + + headers = {"Authorization": f"Bearer {self._get_token()}"} + + response = self._client.post( + f"{self._settings.platform_base_url}/sign/envelopes/{envelope_id}/documents", + headers=headers, + files=files, + ) + + response.raise_for_status() + return response.json() + + # ========== Participant Management ========== + + def create_participant( + self, envelope_id: str, participant_data: dict[str, Any] + ) -> dict[str, Any]: + """Add a participant to an envelope. + + Args: + envelope_id: ID of the envelope + participant_data: Participant configuration with role, email, name + + Returns: + Created participant with ID + """ + return self._request( + "POST", f"/sign/envelopes/{envelope_id}/participants", json_data=participant_data + ) + + # ========== Field Management ========== + + def create_field( + self, envelope_id: str, document_id: str, field_data: dict[str, Any] + ) -> dict[str, Any]: + """Add a signature field to a document in an envelope. + + Args: + envelope_id: ID of the envelope + document_id: ID of the document + field_data: Field configuration with boundingBox, participantID, type, page + + Returns: + Created field with ID + """ + return self._request( + "POST", + f"/sign/envelopes/{envelope_id}/documents/{document_id}/fields", + json_data=field_data, + ) + + # ========== Envelope Actions ========== + + def send_for_signing(self, envelope_id: str) -> dict[str, Any]: + """Send envelope to participants for signing. + + This transitions the envelope from 'drafted' to 'sent' status. + + Args: + envelope_id: UUID of the envelope + + Returns: + Updated envelope with 'sent' status + """ + return self._request("POST", f"/sign/envelopes/{envelope_id}:send-for-signing") + + def cancel_envelope(self, envelope_id: str) -> dict[str, Any]: + """Cancel an envelope that was sent for signing. + + Args: + envelope_id: UUID of the envelope + + Returns: + Envelope with 'cancelled' status + """ + return self._request("PUT", f"/sign/envelopes/{envelope_id}/cancel") + + def send_reminders(self, envelope_id: str) -> dict[str, Any]: + """Send reminder notifications to pending signers. + + Args: + envelope_id: UUID of the envelope + + Returns: + Confirmation of reminder sent + """ + return self._request("POST", f"/sign/envelopes/{envelope_id}/reminders") + + # ========== Document Downloads ========== + + def download_sealed_envelope(self, envelope_id: str) -> bytes: + """Download the sealed (signed and completed) envelope. + + Args: + envelope_id: UUID of the envelope + + Returns: + PDF bytes of the sealed document + """ + return self._request_bytes("GET", f"/sign/envelopes/{envelope_id}:download-sealed") + + def download_original_envelope(self, envelope_id: str) -> bytes: + """Download the original (unsigned) envelope documents. + + Args: + envelope_id: UUID of the envelope + + Returns: + Original document bytes + """ + return self._request_bytes("GET", f"/sign/envelopes/{envelope_id}:download-original") diff --git a/samples/python/batch_process.py b/samples/python/batch_process.py index 52ec9dd..bdf05f5 100644 --- a/samples/python/batch_process.py +++ b/samples/python/batch_process.py @@ -1,39 +1,108 @@ -#!/usr/bin/env python -"""Batch process documents from CLI.""" +#!/usr/bin/env python3 +""" +📁 BATCH DOCUMENT CONVERSION +============================= -import sys +The script exemplifies a typical workflow for document format standardization. +As a IT administrator, it's essential to convert +large collections of documents into standardized formats for archival, compliance, +or system integration purposes. Manually converting individual files through desktop +applications is time-consuming and impractical for large document sets, often leading +to inconsistent results and wasted effort. + +This workflow automates bulk document conversion. The script processes files +individually - for every document matching the specified pattern in the input folder, +it converts the file to the target format (PDF, DOCX, XLSX, PNG, JPG, etc.) using +high-fidelity conversion algorithms. Each converted file is saved to the output +folder with the same base name but the new format extension, resulting in a +complete batch of standardized documents. + +BATCH CONVERSION FEATURES: + ✓ Multiple format support (PDF, DOCX, XLSX, PNG, JPG, etc.) + ✓ Flexible file pattern matching (*.docx, *.xlsx, *.pptx, *.pdf.) + +USAGE: + python batch_process.py [pattern] + +EXAMPLES: + python batch_process.py ../../test_files/test-batch ./output pdf "*.docx" + python batch_process.py ./documents ./converted png "*" +""" + +from enum import Enum from pathlib import Path -from platform_api import PlatformAPIClient +from typing import Annotated -if __name__ == "__main__": - if len(sys.argv) < 4: - print("Usage: python batch_process.py [pattern]") - print("Example: python batch_process.py ./docs ./output pdf '*.docx'") - sys.exit(1) - - input_dir = Path(sys.argv[1]) - output_dir = Path(sys.argv[2]) - to_format = sys.argv[3] - pattern = sys.argv[4] if len(sys.argv) > 4 else "*" - - output_dir.mkdir(exist_ok=True) - files = list(input_dir.glob(pattern)) - - if not files: - print(f"❌ No files matching '{pattern}' found in {input_dir}") - sys.exit(1) - +import typer + +from api.platform_api import PlatformAPIClient +from helper_functions.document_helpers import validate_and_setup + +class OutputFormat(str, Enum): + """Supported output formats for document conversion.""" + + PDF = "pdf" + DOCX = "docx" + XLSX = "xlsx" + PPTX = "pptx" + +app = typer.Typer() + + +@app.command() +def main( + input_folder: Annotated[ + Path, typer.Argument(help="Input folder containing documents to convert") + ], + output_folder: Annotated[Path, typer.Argument(help="Output folder for converted documents")], + to_format: Annotated[ + OutputFormat, + typer.Argument(help="Target format for conversion"), + ], + pattern: Annotated[ + str, + typer.Argument(help="File pattern to match (e.g., '*.docx', '*.pdf', '*')"), + ] = "*", +) -> None: + """Process multiple documents in batch, converting them to a specified format.""" + # Validate and setup with custom pattern + files = validate_and_setup(input_folder, output_folder, file_patterns=[pattern]) + typer.echo(f"📋 Found {len(files)} file(s) matching '{pattern}'\n") + + # Initialize API client (loads credentials from .env) client = PlatformAPIClient() - print(f"📁 Processing {len(files)} files...") - + + # Process each document + success_count = 0 + failed_count = 0 + for i, file_path in enumerate(files, 1): + typer.echo(f"[{i}/{len(files)}] Processing: {file_path.name}") + try: - print(f"[{i}/{len(files)}] Converting {file_path.name}...") - converted = client.convert(file_path, to_format) - output_path = output_dir / f"{file_path.stem}.{to_format}" - output_path.write_bytes(converted) - print(f" ✅ Saved to {output_path.name}") - except Exception as e: - print(f" ❌ Error: {e}") - - print(f"✅ Batch processing complete") + # Convert to target format + typer.echo(f" 🔄 Converting to {to_format.value.upper()}...") + converted = client.convert(file_path, to_format.value) + + # Save converted file + output_file = output_folder / f"{file_path.stem}.{to_format.value}" + output_file.write_bytes(converted) + + typer.echo(f" ✅ Converted: {output_file.name}\n") + success_count += 1 + + except Exception as e: # noqa: BLE001 # pylint: disable=broad-exception-caught + typer.echo(f" ❌ FAILED: {e}\n") + failed_count += 1 + + # Display summary + typer.echo("=" * 60) + typer.echo(f"✅ {success_count} file(s) converted to {to_format.value.upper()}") + if failed_count > 0: + typer.echo(f"⚠️ {failed_count} file(s) FAILED to convert!") + typer.echo(f"📂 Output: {output_folder.absolute()}") + typer.echo("=" * 60) + + +if __name__ == "__main__": + app() diff --git a/samples/python/bulk_password_protect.py b/samples/python/bulk_password_protect.py index 55e34f0..7ad3254 100644 --- a/samples/python/bulk_password_protect.py +++ b/samples/python/bulk_password_protect.py @@ -1,37 +1,96 @@ -#!/usr/bin/env python -"""Password protect PDFs in bulk.""" +#!/usr/bin/env python3 +""" +🔐 BULK PASSWORD PROTECTION +============================ + +The script exemplifies a typical workflow for securing confidential documents. +As a security professional, it's essential to protect sensitive +documents with passwords before distributing them to authorized personnel, storing +them in shared drives, or archiving them for compliance purposes. Manually setting +passwords on individual files is tedious and inconsistent, leading to weak passwords +or missed files that remain unprotected. + +This workflow automates secure document protection. The script processes each PDF +file individually - for every document in the input folder, it applies robust +password encryption using a consistent password across all files. Each protected +file is saved to the output folder with the same filename, ensuring that the entire +batch of documents maintains uniform security standards. The result is a complete +set of password-protected PDFs ready for secure distribution or storage. + +DOCUMENT SECURITY STANDARDS: + ✓ Password encryption (AES-256) + ✓ Batch processing (entire folders) + ✓ Consistent security (uniform password policy) + +USAGE: + python bulk_password_protect.py + +EXAMPLE: + python bulk_password_protect.py ../../test_files/test-pdfs ./output MySecureP@ss123 +""" -import sys from pathlib import Path -from platform_api import PlatformAPIClient - -if __name__ == "__main__": - if len(sys.argv) != 4: - print("Usage: python bulk_password_protect.py ") - sys.exit(1) - - input_dir = Path(sys.argv[1]) - output_dir = Path(sys.argv[2]) - password = sys.argv[3] - - output_dir.mkdir(exist_ok=True) - files = list(input_dir.glob("*.pdf")) - - if not files: - print(f"❌ No PDF files found in {input_dir}") - sys.exit(1) - +from typing import Annotated + +import typer + +from api.platform_api import PlatformAPIClient +from helper_functions.document_helpers import validate_and_setup + +app = typer.Typer() + + +@app.command() +def main( + input_folder: Annotated[Path, typer.Argument(help='Input folder containing PDF documents')], + output_folder: Annotated[Path, typer.Argument(help='Output folder for protected PDFs')], + password: Annotated[str, typer.Argument(help='Password for protection (min 6 characters)')], +) -> None: + """Apply password protection to all PDF files in a directory.""" + # Validate password strength + if len(password) < 6: + print('❌ Error: Password must be at least 6 characters long') + raise typer.Exit(code=1) + + # Validate and setup (only process PDF files) + files = validate_and_setup(input_folder, output_folder, file_patterns=["*.pdf"]) + print(f"📋 Found {len(files)} PDF document(s) to protect\n") + + # Initialize API client (loads credentials from .env) client = PlatformAPIClient() - print(f"🔒 Protecting {len(files)} PDFs with password...") - - for i, file_path in enumerate(files, 1): + + # Process each document + success_count = 0 + failed_count = 0 + + for i, pdf_file in enumerate(files, 1): + print(f"[{i}/{len(files)}] Processing: {pdf_file.name}") + try: - print(f"[{i}/{len(files)}] Protecting {file_path.name}...") - protected = client.password_protect(file_path, password) - output_path = output_dir / file_path.name - output_path.write_bytes(protected) - print(f" ✅ Saved to {output_path.name}") - except Exception as e: - print(f" ❌ Error: {e}") - - print(f"✅ Bulk password protection complete") + # Apply password protection + print(" 🔐 Applying password protection...") + protected_pdf = client.password_protect(pdf_file, password) + + # Save protected PDF + output_file = output_folder / pdf_file.name + output_file.write_bytes(protected_pdf) + + print(f" ✅ Protected: {output_file.name}\n") + success_count += 1 + + except Exception as e: # noqa: BLE001 + print(f" ❌ FAILED: {e}\n") + failed_count += 1 + + # Display summary + print("=" * 60) + print(f"✅ {success_count} document(s) password protected") + if failed_count > 0: + print(f"⚠️ {failed_count} document(s) FAILED - remain unprotected!") + print(f"📂 Output: {output_folder.absolute()}") + print(f"🔑 Password: {'*' * len(password)} ({len(password)} characters)") + print("=" * 60) + + +if __name__ == '__main__': + app() diff --git a/samples/python/convert_cli.py b/samples/python/convert_cli.py index 7cd8a9b..18dae8f 100644 --- a/samples/python/convert_cli.py +++ b/samples/python/convert_cli.py @@ -1,22 +1,93 @@ -#!/usr/bin/env python -"""Document conversion from CLI.""" +#!/usr/bin/env python3 +""" +🔄 SINGLE DOCUMENT CONVERSION +============================== -import sys +The script exemplifies a typical workflow for quick document format conversion. +As a business professional, you often need to convert individual +documents between formats for sharing, presentations, or compatibility requirements. +Whether converting a Word document to PDF for distribution, an Excel spreadsheet to +CSV for data processing, or a presentation to images for web display, manual +conversion through multiple applications is inefficient. + +This workflow provides instant document conversion. The script takes a single input +file and converts it to the specified output format using professional-grade +conversion algorithms. The result is a high-quality converted file that preserves +formatting, structure, and content fidelity, ready for immediate use. + +CONVERSION FEATURES: + ✓ Multiple format support (PDF, DOCX, XLSX, PNG, etc.) + ✓ High-fidelity conversion (preserves formatting) + +USAGE: + python convert_cli.py + +EXAMPLES: + python convert_cli.py document.docx document.pdf pdf + python convert_cli.py presentation.pptx slide.pdf pdf + python convert_cli.py spreadsheet.xlsx data.pdf pdf +""" + +from enum import Enum from pathlib import Path -from platform_api import PlatformAPIClient - -if __name__ == "__main__": - if len(sys.argv) != 4: - print("Usage: python convert_cli.py ") - print("Formats: pdf, docx, xlsx, png, jpg") - sys.exit(1) - - input_path = Path(sys.argv[1]) - output_path = Path(sys.argv[2]) - to_format = sys.argv[3] - +from typing import Annotated + +import typer + +from api.platform_api import PlatformAPIClient + +class OutputFormat(str, Enum): + """Supported output formats for document conversion.""" + + PDF = "pdf" + DOCX = "docx" + XLSX = "xlsx" + PPTX = "pptx" + PNG = "png" + + +app = typer.Typer() + + +@app.command() +def main( + input_file: Annotated[Path, typer.Argument(help='Input file to convert')], + output_file: Annotated[Path, typer.Argument(help='Output file path')], + to_format: Annotated[ + OutputFormat, + typer.Argument(help="Target format for conversion"), + ], +) -> None: + """Convert a document from one format to another using the Platform API.""" + # Validate input file exists + if not input_file.exists(): + print(f'❌ Error: Input file not found: {input_file}') + raise typer.Exit(code=1) + + # Create output directory if needed + output_file.parent.mkdir(parents=True, exist_ok=True) + + # Initialize API client (loads credentials from .env) client = PlatformAPIClient() - print(f"🔄 Converting {input_path.name} to {to_format}...") - converted = client.convert(input_path, to_format) - output_path.write_bytes(converted) - print(f"✅ Saved to {output_path}") + + try: + # Convert document + print(f'🔄 Converting {input_file.name} to {to_format.value.upper()}...') + converted = client.convert(input_file, to_format.value) + + # Save converted file + output_file.write_bytes(converted) + + # Display success message + print('✅ Conversion successful!') + print(f'📄 Input: {input_file.name} ({input_file.stat().st_size:,} bytes)') + print(f'📄 Output: {output_file.name} ({len(converted):,} bytes)') + print(f'📂 Saved to: {output_file.absolute()}') + + except Exception as e: # noqa: BLE001 + print(f'❌ Conversion FAILED: {e}') + raise typer.Exit(code=1) from None + + +if __name__ == '__main__': + app() diff --git a/samples/python/employee_policy_onboarding.py b/samples/python/employee_policy_onboarding.py new file mode 100644 index 0000000..b28d52c --- /dev/null +++ b/samples/python/employee_policy_onboarding.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +""" +📝 EMPLOYEE POLICY ONBOARDING +============================== + +This script exemplifies a typical HR onboarding workflow for new employees. +As an HR professional, it's necessary to ensure all new hires review and sign +required company policies before their start date. Manual distribution and +tracking of signatures is time-consuming and error-prone, especially when +onboarding multiple employees simultaneously. + +This workflow automates policy distribution and signature collection. The script +processes each new employee individually - for every person in the CSV file, it +creates a signature envelope containing all company policy documents, sends it +via email with signature fields pre-configured, monitors the signing status, and +automatically downloads the signed documents once completed. Each employee's +signed policies are organized in their own folder, creating an audit-ready +archive of onboarding documentation. + + +NOTE: To run this script and see the complete workflow, you must provide a CSV file +with valid employee names and email addresses. The script will send actual signature +requests to these email addresses and wait for them to be signed. + +EMPLOYEE CSV FORMAT: + name,email + John Doe,john.doe@company.com + Jane Smith,jane.smith@company.com + Bob Johnson,bob.johnson@company.com + +USAGE: + python employee_policy_onboarding.py + +EXAMPLE: + python employee_policy_onboarding.py ./policies ./new_hires_jan2025.csv + +OUTPUT STRUCTURE: + output/ + ├── john-doe/ + │ ├── signed-documents/ + │ ├── code-of-conduct-signed.pdf + │ ├── confidentiality-agreement-signed.pdf + │ └── audit-trail.pdf + ├── jane-smith/ + ├── signed-documents/ +""" + +from pathlib import Path +from typing import Annotated, Any + +import typer + +from api.sign_api import SignAPIClient +from helper_functions.sign_helpers import ( + add_signature_fields_to_documents, + create_employee_folder_name, + create_signature_envelope, + download_signed_document, + load_employees_from_csv, + load_policy_documents_from_folder, + log_step, + send_and_monitor_envelope, +) + + +class EnvelopeNotSignedError(Exception): + """Raised when an envelope was not signed in time.""" + + +app = typer.Typer() + + +def _validate_and_setup_inputs( + policies_folder: Path, employees_csv: Path +) -> tuple[Path, list[dict[str, str]], list[dict[str, Any]]]: + """Validate inputs and load data. + + Args: + policies_folder: Path to folder containing policy PDFs + employees_csv: Path to CSV file with employee data + + Returns: + Tuple of (output_folder, employees, documents) + """ + # Validate inputs + if not policies_folder.exists() or not policies_folder.is_dir(): + print(f'❌ Policies folder not found: {policies_folder}') + raise typer.Exit(code=1) + + if not employees_csv.exists(): + print(f'❌ Employees CSV not found: {employees_csv}') + raise typer.Exit(code=1) + + # Display header + output_folder = Path('output') + print('=' * 60) + print('📝 SEND POLICIES TO EMPLOYEES') + print('=' * 60) + print(f'Policies: {policies_folder}') + print(f'Employees: {employees_csv}') + print(f'Output: {output_folder}') + print('=' * 60) + print() + + # Load employees and documents + employees = load_employees_from_csv(employees_csv) + print(f'👥 Found {len(employees)} employee(s)\n') + + documents = load_policy_documents_from_folder(policies_folder) + + # Create output folder + output_folder.mkdir(parents=True, exist_ok=True) + + return output_folder, employees, documents + + +def _process_employee_onboarding( + sign_client: SignAPIClient, + employee: dict[str, str], + documents: list[dict[str, Any]], + output_folder: Path, + *, + employee_num: int, + total_employees: int, +) -> None: + """Process onboarding workflow for a single employee. + + Args: + sign_client: Sign API client instance + employee: Employee data dict with 'name' and 'email' + documents: List of policy documents to send + output_folder: Base output folder for signed documents + employee_num: Current employee number (for display) + total_employees: Total number of employees (for display) + + Raises: + EnvelopeNotSignedError: If envelope is not signed within timeout + Exception: For other errors during processing + """ + name = employee['name'] + email = employee['email'] + + print(f'\n[{employee_num}/{total_employees}] {name}') + + # Create employee-specific output folder + employee_folder = output_folder / create_employee_folder_name(name) + employee_folder.mkdir(parents=True, exist_ok=True) + + # Create envelope and upload documents + log_step('📝 Creating envelope...') + envelope_id, document_ids = create_signature_envelope(sign_client, documents, name, email) + + # Add participant (signer) + log_step('👤 Adding signer...') + participant_id = sign_client.create_participant( + envelope_id, {'email': email, 'role': 'signer', 'name': name} + )['ID'] + + # Add signature fields to all documents + log_step('✍️ Adding fields...') + add_signature_fields_to_documents(sign_client, envelope_id, document_ids, participant_id) + + # Send and monitor envelope + log_step('📤 Sending...') + status = send_and_monitor_envelope(sign_client, envelope_id, email, timeout_minutes=60) + + if status != 'sealed': + raise EnvelopeNotSignedError(f'Envelope not signed: {status}') + + # Download signed documents + log_step('📥 Downloading...') + download_signed_document(sign_client, envelope_id, employee_folder, 'signed-policies.zip') + + print(' ✅ Completed\n') + + +@app.command() +def main( + policies_folder: Annotated[ + Path, typer.Argument(help='Folder containing policy PDF documents') + ], + employees_csv: Annotated[ + Path, typer.Argument(help='CSV file with employee data (name,email columns)') + ], +) -> None: + """Send company policy documents to employees for electronic signature via Sign API.""" + try: + # Validate inputs and load data + output_folder, employees, documents = _validate_and_setup_inputs( + policies_folder, employees_csv + ) + + # Initialize Sign API client + sign_client = SignAPIClient() + + # Process each employee + print("=" * 60) + print(f"📤 PROCESSING {len(employees)} EMPLOYEE(S)") + print("=" * 60) + + success_count = 0 + failed_count = 0 + + for i, employee in enumerate(employees, 1): + try: + _process_employee_onboarding( + sign_client, + employee, + documents, + output_folder, + employee_num=i, + total_employees=len(employees), + ) + success_count += 1 + + except Exception as e: # noqa: BLE001 + print(f" ❌ FAILED: {e}\n") + failed_count += 1 + + # Display summary + print("=" * 60) + print(f"✅ {success_count} employee(s) completed") + if failed_count > 0: + print(f"❌ {failed_count} failed") + print(f"📂 Output: {output_folder.absolute()}") + print("=" * 60) + + except KeyboardInterrupt: + print('\n\n⚠️ Interrupted by user') + raise typer.Exit(code=1) from None + except Exception as e: # noqa: BLE001 + print(f'\n❌ Error: {e}') + raise typer.Exit(code=1) from None + + +if __name__ == '__main__': + app() diff --git a/samples/python/extract_data.py b/samples/python/extract_data.py index 433a1f5..3e5e5b1 100644 --- a/samples/python/extract_data.py +++ b/samples/python/extract_data.py @@ -1,31 +1,114 @@ -#!/usr/bin/env python -"""Extract forms and tables from documents.""" +#!/usr/bin/env python3 +""" +📊 DOCUMENT DATA EXTRACTION +============================ + +The script exemplifies a typical workflow for intelligent document data extraction. +As a data analyst, you need to extract structured +data from PDF documents - whether form fields from applications, surveys, and +questionnaires, or table data from reports, invoices, and financial statements. +Manual data entry is error-prone and time-consuming, especially when processing +hundreds of documents for analysis or database import. + +This workflow automates data extraction using AI-powered document understanding. +The script analyzes PDF documents and intelligently identifies and extracts either +form fields (with field names and values) or table structures (with rows, columns, +and cell contents). The extracted data is saved as structured JSON, ready for +immediate integration with databases, spreadsheets, or analytics pipelines. + +DATA EXTRACTION FEATURES: + ✓ AI-powered form field extraction + ✓ Intelligent table detection and extraction + ✓ Structured JSON output format + ✓ High accuracy recognition + +USAGE: + python extract_data.py + +MODES: + forms - Extract form fields (name-value pairs) + tables - Extract table data (rows and columns) + +EXAMPLES: + python extract_data.py forms application.pdf data.json + python extract_data.py tables invoice.pdf tables.json +""" -import sys import json from pathlib import Path -from platform_api import PlatformAPIClient - -if __name__ == "__main__": - if len(sys.argv) != 4: - print("Usage: python extract_data.py ") - sys.exit(1) - - mode = sys.argv[1] - input_path = Path(sys.argv[2]) - output_path = Path(sys.argv[3]) - +from typing import Annotated + +import typer + +from api.platform_api import PlatformAPIClient + +app = typer.Typer() + + +@app.command() +def main( + mode: Annotated[str, typer.Argument(help="Extraction mode: 'forms' or 'tables'")], + input_pdf: Annotated[Path, typer.Argument(help='Input PDF file')], + output_json: Annotated[Path, typer.Argument(help='Output JSON file')], +) -> None: + """Extract structured data (forms or tables) from PDF documents.""" + # Normalize mode to lowercase + mode = mode.lower() + + # Validate mode + if mode not in ['forms', 'tables']: + print("❌ Error: Mode must be 'forms' or 'tables'") + raise typer.Exit(code=1) + + # Validate input file exists + if not input_pdf.exists(): + print(f'❌ Error: Input file not found: {input_pdf}') + raise typer.Exit(code=1) + + # Validate input is a PDF + if input_pdf.suffix.lower() != '.pdf': + print('❌ Error: Input must be a PDF file') + raise typer.Exit(code=1) + + # Create output directory if needed + output_json.parent.mkdir(parents=True, exist_ok=True) + + # Initialize API client (loads credentials from .env) client = PlatformAPIClient() - - if mode == "forms": - print(f"📋 Extracting form data from {input_path.name}...") - data = client.extract_forms(input_path) - elif mode == "tables": - print(f"📊 Extracting table data from {input_path.name}...") - data = client.extract_tables(input_path) - else: - print("❌ Mode must be 'forms' or 'tables'") - sys.exit(1) - - output_path.write_text(json.dumps(data, indent=2)) - print(f"✅ Saved to {output_path}") + + try: + # Extract data based on mode + if mode == 'forms': + print(f'📋 Extracting form fields from {input_pdf.name}...') + data = client.extract_forms(input_pdf) + data_type = 'form fields' + + else: # mode == 'tables' + print(f'📊 Extracting table data from {input_pdf.name}...') + data = client.extract_tables(input_pdf) + data_type = 'tables' + + # Count extracted items + result = data.get('result', {}) + if mode == 'forms': + item_count = len(result.get('fields', [])) + else: + item_count = len(result.get('tables', [])) + + # Save extracted data as JSON + output_json.write_text(json.dumps(data, indent=2), encoding='utf-8') + + # Display success message + print('✅ Extraction successful!') + print(f'📊 Extracted: {item_count} {data_type}') + print(f'📄 Input: {input_pdf.name}') + print(f'📄 Output: {output_json.name}') + print(f'📂 Saved to: {output_json.absolute()}') + + except Exception as e: # noqa: BLE001 + print(f'❌ Extraction FAILED: {e}') + raise typer.Exit(code=1) from None + + +if __name__ == '__main__': + app() diff --git a/samples/python/helper_functions/document_helpers.py b/samples/python/helper_functions/document_helpers.py new file mode 100644 index 0000000..3be16a4 --- /dev/null +++ b/samples/python/helper_functions/document_helpers.py @@ -0,0 +1,47 @@ +""" +Common helper utilities for document processing scripts. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +def validate_and_setup( + input_folder: Path, output_folder: Path, file_patterns: list[str] | None = None +) -> list[Path]: + """ + Validate input folder and setup output folder. Returns list of files to process. + + Args: + input_folder: Path to the input directory containing files to process + output_folder: Path to the output directory for processed files + file_patterns: List of glob patterns to match files (e.g., ['*.docx', '*.pdf']) + If None, defaults to common Office document formats + + Returns: + List of Path objects for files matching the patterns + """ + # Default patterns for Office documents if none provided + if file_patterns is None: + file_patterns = ["*.docx", "*.doc", "*.xlsx", "*.xls", "*.pptx", "*.ppt"] + + # Validate input folder exists + if not input_folder.exists() or not input_folder.is_dir(): + print(f"❌ Error: Invalid input folder: {input_folder}") + sys.exit(1) + + # Create output folder if needed + output_folder.mkdir(parents=True, exist_ok=True) + + # Find all matching files + files: list[Path] = [] + for pattern in file_patterns: + files.extend(input_folder.glob(pattern)) + + if not files: + print(f"❌ No files matching patterns {file_patterns} found in {input_folder}") + sys.exit(1) + + return files diff --git a/samples/python/helper_functions/sign_helpers.py b/samples/python/helper_functions/sign_helpers.py new file mode 100644 index 0000000..22f47f8 --- /dev/null +++ b/samples/python/helper_functions/sign_helpers.py @@ -0,0 +1,356 @@ +""" +Sign API helper utilities for envelope operations. +""" + +from __future__ import annotations + +import csv +import json +import time +import zipfile +from datetime import UTC, datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import httpx + +if TYPE_CHECKING: + from api.sign_api import SignAPIClient + + +def create_employee_folder_name(employee_name: str) -> str: + """Convert employee name to folder-safe name. + + Args: + employee_name: Full name like "John Doe" + + Returns: + Folder-safe name like "john-doe" + """ + return employee_name.lower().replace(" ", "-").replace(".", "") + + +def load_employees_from_csv(csv_path: Path) -> list[dict[str, str]]: + """Load employee list from CSV file. + + Args: + csv_path: Path to CSV file with columns: name, email + + Returns: + List of employee dictionaries with 'name' and 'email' + + Raises: + ValueError: If CSV format is invalid + """ + if not csv_path.exists(): + raise ValueError(f"CSV file not found: {csv_path}") + + employees: list[dict[str, str]] = [] + + with csv_path.open(encoding="utf-8") as f: + reader = csv.DictReader(f) + + # Validate CSV has required columns + fieldnames = reader.fieldnames + if not fieldnames or "name" not in fieldnames or "email" not in fieldnames: + raise ValueError('CSV must have "name" and "email" columns') + + for row in reader: + name = row["name"].strip() + email = row["email"].strip() + + if name and email and "@" in email: + employees.append({"name": name, "email": email}) + + if not employees: + raise ValueError("No valid employees found in CSV") + + return employees + + +def load_policy_documents_from_folder(policies_folder: Path) -> list[dict[str, Any]]: + """Load all PDF files from the policies folder. + + Args: + policies_folder: Path to folder containing policy PDFs + + Returns: + List of document dictionaries with 'name', 'binary', and 'path' + """ + policy_files = list(policies_folder.glob("*.pdf")) + + if not policy_files: + raise ValueError(f"No PDF files found in {policies_folder}") + + print(f"📂 Found {len(policy_files)} policy document(s)\n") + + documents: list[dict[str, Any]] = [] + for pf in policy_files: + with pf.open("rb") as f: + binary_data = f.read() + + documents.append({"name": pf.name, "binary": binary_data, "path": str(pf)}) + + return documents + + +def _upload_documents_to_envelope( + sign_client: SignAPIClient, envelope_id: str, documents: list[dict[str, Any]] +) -> list[str]: + """Upload documents to an existing envelope. + + Args: + sign_client: Sign API client instance + envelope_id: ID of the envelope to upload to + documents: List of document dicts with 'name', 'binary', 'path' + + Returns: + List of document IDs + """ + document_ids: list[str] = [] + + for doc in documents: + doc_name = doc["name"] + doc_binary = doc["binary"] + + # Prepare metadata as JSON string + metadata = json.dumps({"name": doc_name}) + + # Prepare form-data with binary content + files = { + "metadata": ("metadata", metadata, "application/json"), + "payload": (doc_name, doc_binary, "application/pdf"), + } + + token = sign_client.get_token() + headers = {"Authorization": f"Bearer {token}"} + + response = sign_client._client.post( + f"{sign_client._settings.platform_base_url}/sign/envelopes/{envelope_id}/documents", + headers=headers, + files=files, + ) + + response.raise_for_status() + document = response.json() + + document_id = document["ID"] + document_ids.append(document_id) + + return document_ids + + +def create_signature_envelope( + sign_client: SignAPIClient, + documents: list[dict[str, Any]], + employee_name: str, + _employee_email: str, +) -> tuple[str, list[str]]: + """Create envelope and upload documents. + + Args: + sign_client: Sign API client instance + documents: List of document dicts with 'name', 'binary', 'path' + employee_name: Full name of employee + employee_email: Email address of employee + + Returns: + Tuple of (envelope_id, list of document_ids) + """ + # Create empty envelope + envelope_data = { + "name": f"Company Policies - {employee_name}", + "mode": "parallel", + "notification": { + "subject": "Please sign: Company Policies", + "body": ( + f"Hello {employee_name}, please review and sign the attached " + "company policy documents." + ), + }, + } + + envelope = sign_client.create_envelope(envelope_data) + envelope_id = envelope["ID"] + + # Upload documents to envelope + document_ids = _upload_documents_to_envelope(sign_client, envelope_id, documents) + + return envelope_id, document_ids + + +def add_signature_fields_to_documents( + sign_client: SignAPIClient, + envelope_id: str, + document_ids: list[str], + participant_id: str, +) -> None: + """Add signature and date fields to all documents in envelope. + + Args: + sign_client: Sign API client instance + envelope_id: ID of the envelope + document_ids: List of document IDs to add fields to + participant_id: ID of the participant who will sign + """ + for doc_id in document_ids: + # Add signature field (positioned bottom-left of page) + # Coordinates: [x, y, width, height] in points (72 points = 1 inch) + # Standard letter page: 612 x 792 points + signature_field_data = { + "participantID": participant_id, + "type": "signature", + "label": "Your Signature", + "page": 1, + "boundingBox": [50, 100, 200, 50], # Bottom area, safe coordinates + "required": True, + } + sign_client.create_field(envelope_id, doc_id, signature_field_data) + + # Add date field (positioned to the right of signature) + date_field_data = { + "participantID": participant_id, + "type": "date", + "label": "Date Signed", + "page": 1, + "boundingBox": [270, 100, 150, 50], # Next to signature, safe coordinates + "required": True, + "format": "MM/DD/YYYY", + } + sign_client.create_field(envelope_id, doc_id, date_field_data) + + +def send_and_monitor_envelope( + sign_client: SignAPIClient, envelope_id: str, email: str, timeout_minutes: int = 60 +) -> str: + """Send envelope and monitor until signed or timeout. + + Args: + sign_client: Sign API client instance + envelope_id: ID of envelope to send + email: Email address of recipient + timeout_minutes: Maximum time to wait + + Returns: + Final status: 'sealed', 'cancelled', 'timeout', or 'error' + """ + # Send envelope + sign_client.send_for_signing(envelope_id) + + # Log send time and status + send_time = datetime.now(tz=UTC).strftime("%Y-%m-%d %H:%M:%S") + print(f" ✅ Sent at: {send_time}") + print(f" 📧 Email sent to: {email}") + print(f" 🔗 Envelope ID: {envelope_id}") + + # Check initial status + envelope = sign_client.get_envelope(envelope_id) + print(f" 📊 Status: {envelope['status']}") + + # Monitor for completion + print(" ⏳ Waiting for signature...") + print(f" ⏱️ Checking every 30 seconds (timeout: {timeout_minutes} minutes)") + + status = monitor_envelope(sign_client, envelope_id, timeout_minutes) + + # Log final status + completion_time = datetime.now(tz=UTC).strftime("%Y-%m-%d %H:%M:%S") + print(f" 📊 Final status: {status}") + print(f" 🕐 Completed at: {completion_time}") + + return status + + +def monitor_envelope( + sign_client: SignAPIClient, envelope_id: str, timeout_minutes: int = 60 +) -> str: + """Monitor envelope until signed, cancelled, or timeout. + + Args: + sign_client: Sign API client instance + envelope_id: ID of envelope to monitor + timeout_minutes: Maximum time to wait + + Returns: + Final status: 'sealed', 'cancelled', 'timeout', or 'error' + """ + check_interval = 30 # seconds + max_checks = (timeout_minutes * 60) // check_interval + + for i in range(max_checks): + try: + envelope = sign_client.get_envelope(envelope_id) + status = envelope["status"] + + if status == "sealed": + return "sealed" + if status in ["cancelled", "rejected", "deleted"]: + return "cancelled" + + if i < max_checks - 1: + time.sleep(check_interval) + + except Exception as e: # noqa: BLE001 + print(f" ⚠️ Error checking status: {e}") + return "error" + + return "timeout" + + +def download_signed_document( + sign_client: SignAPIClient, envelope_id: str, output_folder: Path, document_name: str +) -> Path: + """Download sealed envelope and extract signed documents. + + The API returns a ZIP file containing: + - All signed PDF documents + - Audit trail document + + This function extracts the contents and removes the ZIP file. + + Args: + sign_client: Sign API client instance + envelope_id: ID of sealed envelope + output_folder: Employee-specific output folder + document_name: Name for the saved file (should end with .zip) + + Returns: + Path to extracted documents folder + """ + # Download sealed envelope (returns ZIP file) + zip_bytes = sign_client.download_sealed_envelope(envelope_id) + + # Save as temporary ZIP file + if not document_name.endswith(".zip"): + document_name = document_name.replace(".pdf", ".zip") + + temp_zip_path = output_folder / document_name + temp_zip_path.write_bytes(zip_bytes) + + # Extract the ZIP contents + extract_folder = output_folder / "signed-documents" + extract_folder.mkdir(exist_ok=True) + + with zipfile.ZipFile(temp_zip_path, "r") as zip_ref: + zip_ref.extractall(extract_folder) + + # Delete the ZIP file after extraction + temp_zip_path.unlink() + + # Save envelope metadata + envelope = sign_client.get_envelope(envelope_id) + json_path = output_folder / "envelope-info.json" + json_path.write_text(json.dumps(envelope, indent=2)) + + print(f" 💾 Extracted to: {extract_folder}") + + return extract_folder + + +def log_step(message: str) -> None: + """Log a processing step with consistent formatting. + + Args: + message: The message to log + """ + print(f" {message}") diff --git a/samples/python/prepare_pdf_for_distribution.py b/samples/python/prepare_pdf_for_distribution.py new file mode 100755 index 0000000..a44a15e --- /dev/null +++ b/samples/python/prepare_pdf_for_distribution.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +🔒 PREPARE PDF FOR DISTRIBUTION +================================ + +The script exemplifies a typical workflow of marketing brochure distribution. +As a marketing professional, it's necessary to share company brochures externally +while ensuring they comply with corporate distribution standards. Word document +properties can expose internal information such as author names, template paths, +revision history, and company file structures that should remain confidential. + +This workflow automates compliant document preparation. The script processes each +file individually - for every brochure in the input folder, it converts the Word +document into PDF format, then compresses the file to reduce size and optimize +transmission, and finally removes all metadata properties to ensure privacy and +confidentiality. Each processed file is saved to the output folder, resulting in +distribution-ready brochures. + +COMPANY DISTRIBUTION STANDARDS: + ✓ PDF format (prevents editing) + ✓ Compressed (optimized file size) + ✓ Properties removed (no metadata exposure) + ⏳ Annotations removed (feature in development) + ⏳ Accessibility enabled (feature in development) + +USAGE: + python prepare_pdf_for_distribution.py + +EXAMPLE: + python prepare_pdf_for_distribution.py ../../test_files/test-batch ./output +""" + +from pathlib import Path +from typing import Annotated + +import typer + +from api.platform_api import PlatformAPIClient +from helper_functions.document_helpers import validate_and_setup + +# Configuration: Properties to remove from PDFs +PROPERTIES_TO_REMOVE = ["title", "author", "subject", "keywords", "creator", "producer"] + + +app = typer.Typer() + + +@app.command() +def main( + input_folder: Annotated[Path, typer.Argument(help='Input folder containing documents')], + output_folder: Annotated[Path, typer.Argument(help='Output folder for prepared PDFs')], +) -> None: + """Prepare documents for distribution by converting to PDF and removing metadata.""" + + # Validate and setup + files = validate_and_setup(input_folder, output_folder) + print(f"📋 Found {len(files)} document(s) to process\n") + + # Initialize API client (loads credentials from .env) + client = PlatformAPIClient() + + # Process each document + success_count = 0 + failed_count = 0 + + for i, doc in enumerate(files, 1): + print(f"[{i}/{len(files)}] Processing: {doc.name}") + + temp_pdf = None + try: + # Step 1: Convert to PDF + print(" 🔐 Converting to PDF...") + pdf_bytes = client.convert(doc, "pdf") + + temp_pdf = output_folder / f"{doc.stem}_temp.pdf" + temp_pdf.write_bytes(pdf_bytes) + + # Step 2: Compress PDF + print(" 📦 Compressing...") + compressed_pdf = client.compress(temp_pdf, level=2) + + temp_pdf.write_bytes(compressed_pdf) + + # Step 3: Remove metadata properties + print(" 🔒 Removing metadata...") + properties_to_clear = dict.fromkeys(PROPERTIES_TO_REMOVE, "") + clean_pdf = client.set_properties(temp_pdf, properties_to_clear) + + # Save final PDF + final_pdf = output_folder / f"{doc.stem}.pdf" + final_pdf.write_bytes(clean_pdf) + temp_pdf.unlink() + + print(f" ✅ Secured: {final_pdf.name}\n") + success_count += 1 + + except Exception as e: # noqa: BLE001 + print(f" ❌ FAILED: {e}\n") + failed_count += 1 + if temp_pdf and temp_pdf.exists(): + temp_pdf.unlink() + + # Display summary + print("=" * 60) + print(f"✅ {success_count} document(s) secured") + if failed_count > 0: + print(f"⚠️ {failed_count} document(s) FAILED - do NOT distribute!") + print(f"📂 Output: {output_folder.absolute()}") + print("=" * 60) + + +if __name__ == '__main__': + app() diff --git a/samples/python/pyproject.toml b/samples/python/pyproject.toml new file mode 100644 index 0000000..2769f9f --- /dev/null +++ b/samples/python/pyproject.toml @@ -0,0 +1,115 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = [] + +[project] +name = "nitro-platform-samples" +version = "0.1.0" +description = "Nitro Platform API samples and examples" +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "httpx>=0.27.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", + "python-dotenv>=1.0.0", + "reportlab>=4.0.0", + "typer>=0.9.0", +] +[tool.ruff] +format.preview = true +target-version = "py314" +line-length = 100 +lint.select = [ + "A", # builtins shadowing + "ANN", # type annotations + "ARG", # unused arguments + "B", # bugbear (common bugs & bad patterns) + "BLE", # blind excepts + "C4", # comprehensions + "DTZ", # datetime timezone + "E", # pycodestyle errors + "ERA", # eradicate commented-out code + "FA", # future annotations + "FBT", # boolean trap + "F", # pyflakes + "FURB", # refurb - modern Python idioms + "I", # import sorting + "ICN", # import conventions + "ISC", # implicit str concat + "N", # naming conventions + "NPY", # numpy rules + "PGH", # pygrep-hooks + "PTH", # pathlib + "PT", # pytest style + "RET", # return statements + "RUF", # Ruff extra rules + "TRY", # tryceratops + "S", # security + "SIM", # simplifications + "C90", # mccabe complexity + "TID", # tidy imports + "TC", # typing under TYPE_CHECKING + "TD", # TODOs must be annotated + "UP", # pyupgrade / newer syntax + "W", # pycodestyle warnings + "YTT", # flake8-2020 + "T10", # debugger statements +] +lint.ignore = ["S101", "S311", "TRY003", "TC003", "TC006"] +lint.flake8-type-checking.strict = true + +[tool.pyright] +typeCheckingMode = "strict" + +# Pylint-------------------------------------------------------------------------- +# https://github.com/atlassian-api/atlassian-python-api/blob/master/pyproject.toml +# Pylint-------------------------------------------------------------------------- +[tool.pylint.FORMAT] +max-line-length = 100 + +[tool.pylint.MASTER] +jobs = 4 +ignore-paths = '.venv' + +[tool.pylint.REPORTS] +output-format = "colorized" +reports = "no" + +[tool.pylint.TYPECHECK] +generated-members = [] + +[tool.pylint.BASIC] +good-names-rgxs = [".*Dep$"] +good-names = [ + "i", + "j", + "n", +] + +# [tool.pylint.SPELLING] +# Spelling checker disabled - requires language dictionaries to be installed +# spelling-private-dict-file = "private-dictionary.txt" +# spelling-dict = "en_GB" + +[tool.pylint.DESIGN] +max-args = 7 +min-public-methods = 1 +max-positional-arguments = 5 + + +[tool.pylint.LOGGING] + +[tool.pylint.'MESSAGES CONTROL'] +disable = ["logging-fstring-interpolation", "wrong-import-order"] + +[project.optional-dependencies] +dev = [ + "pyenchant>=3.3.0", + "pylint>=4.0.4", + "pyright>=1.1.407", + "ruff>=0.8.0", +] \ No newline at end of file diff --git a/samples/python/quickstart.py b/samples/python/quickstart.py index 300d9b2..cbc34fc 100644 --- a/samples/python/quickstart.py +++ b/samples/python/quickstart.py @@ -1,45 +1,49 @@ +"""Quickstart script to test Nitro Platform API authentication and connection.""" + import os -import requests + +import httpx from dotenv import load_dotenv load_dotenv() -BASE_URL = os.getenv('PLATFORM_BASE_URL', 'https://api.gonitro.dev') -CLIENT_ID = os.getenv('PLATFORM_CLIENT_ID') -CLIENT_SECRET = os.getenv('PLATFORM_CLIENT_SECRET') +BASE_URL = os.getenv("PLATFORM_BASE_URL", "https://api.gonitro.dev") +CLIENT_ID = os.getenv("PLATFORM_CLIENT_ID") +CLIENT_SECRET = os.getenv("PLATFORM_CLIENT_SECRET") + -def get_access_token(): +def get_access_token() -> str: """Get OAuth2 access token using client credentials""" url = f"{BASE_URL}/oauth/token" - data = { - "clientID": CLIENT_ID, - "clientSecret": CLIENT_SECRET - } - - response = requests.post(url, json=data) + data = {"clientID": CLIENT_ID, "clientSecret": CLIENT_SECRET} + + response = httpx.post(url, json=data) response.raise_for_status() - return response.json()['accessToken'] + return response.json()["accessToken"] + -def test_connection(token): +def test_connection(token: str) -> bool | None: """Test API connection with a simple request""" url = f"{BASE_URL}/jobs/test-job-id/status" headers = {"Authorization": f"Bearer {token}"} - + try: - response = requests.get(url, headers=headers) + response = httpx.get(url, headers=headers) # 404 is expected for non-existent job, but proves auth works if response.status_code == 404: print("✅ Authentication successful (404 expected for test job ID)") return True response.raise_for_status() - return True - except requests.exceptions.HTTPError as e: + return True # noqa: TRY300 + except httpx.HTTPStatusError as e: if e.response.status_code == 404: print("✅ Authentication successful (404 expected for test job ID)") return True raise -def main(): + +def main() -> None: + """Test API authentication and connection.""" if not CLIENT_ID or not CLIENT_SECRET: print("❌ Missing credentials!") print("To get your credentials:") @@ -51,21 +55,22 @@ def main(): print(" export PLATFORM_CLIENT_ID=") print(" export PLATFORM_CLIENT_SECRET=") return - + try: print("🔐 Getting access token...") token = get_access_token() print("✅ Token obtained successfully") - + print("🧪 Testing API connection...") test_connection(token) print("✅ API connection successful") - + print("\n🎉 Setup complete! You can now use the Platform API.") print("📖 See https://developers.gonitro.com/docs for API documentation") - - except Exception as e: + + except Exception as e: # noqa: BLE001 print(f"❌ Error: {e}") + if __name__ == "__main__": main() diff --git a/samples/python/redact_by_keyword.py b/samples/python/redact_by_keyword.py index a84b81c..06c21b9 100644 --- a/samples/python/redact_by_keyword.py +++ b/samples/python/redact_by_keyword.py @@ -1,35 +1,114 @@ -#!/usr/bin/env python -"""Redact by keyword search.""" +#!/usr/bin/env python3 +""" +🔍 KEYWORD-BASED REDACTION +=========================== + +The script exemplifies a typical workflow for targeted content redaction. +As a compliance officer, you need to redact specific +sensitive terms from documents before external sharing or public disclosure. +Whether removing client names, project codenames, financial figures, or +proprietary terminology, manually searching through pages and applying redactions +is tedious and risks missing instances, potentially exposing confidential information. + +This workflow automates keyword-based redaction. The script searches the entire +PDF document for all specified keywords and phrases, identifies their exact +locations across all pages, then automatically applies permanent redactions to +remove them. Multiple keywords can be processed in a single pass, ensuring +comprehensive coverage. The result is a thoroughly redacted document ready for +safe distribution. + +KEYWORD REDACTION FEATURES: + ✓ Multi-keyword search (process multiple terms) + ✓ Whole document scanning (all pages) + ✓ Exact location detection + ✓ Permanent redaction (unrecoverable) + ⏳ Case-insensitive matching (feature in development) + ⏳ Regex pattern support (feature in development) + +USAGE: + python redact_by_keyword.py [keyword2 ...] + +EXAMPLES: + python redact_by_keyword.py contract.pdf redacted.pdf "confidential" "proprietary" + python redact_by_keyword.py report.pdf clean.pdf "Project Zeus" "Client ABC" +""" -import sys from pathlib import Path -from platform_api import PlatformAPIClient - -if __name__ == "__main__": - if len(sys.argv) < 4: - print("Usage: python redact_by_keyword.py [keyword2 ...]") - sys.exit(1) - - input_path = Path(sys.argv[1]) - output_path = Path(sys.argv[2]) - keywords = sys.argv[3:] - +from typing import Annotated + +import typer + +from api.platform_api import PlatformAPIClient + +app = typer.Typer() + + +@app.command() +def main( + input_pdf: Annotated[Path, typer.Argument(help='Input PDF file to redact')], + output_pdf: Annotated[Path, typer.Argument(help='Output PDF file with redactions')], + keywords: Annotated[list[str], typer.Argument(help='Keywords to search for and redact')], +) -> None: + """Redact specific keywords from PDF documents using text search.""" + # Validate input file exists + if not input_pdf.exists(): + print(f'❌ Error: Input file not found: {input_pdf}') + raise typer.Exit(code=1) + + # Validate input is a PDF + if input_pdf.suffix.lower() != '.pdf': + print('❌ Error: Input must be a PDF file') + raise typer.Exit(code=1) + + # Create output directory if needed + output_pdf.parent.mkdir(parents=True, exist_ok=True) + + # Initialize API client (loads credentials from .env) client = PlatformAPIClient() - - print(f"🔍 Finding keywords in {input_path.name}...") - bbox_data = client.find_text_boxes(input_path, keywords) - - text_boxes = bbox_data.get('result', {}).get('textBoxes', []) - if not text_boxes: - print("✅ No keywords found") - sys.exit(0) - - print(f"🎯 Found {len(text_boxes)} keyword instances") - print(f"🔒 Redacting keywords...") - - redactions = [{"pageIndex": box["pageIndex"], "boundingBox": box["boundingBox"]} - for box in text_boxes] - - redacted = client.redact(input_path, redactions) - output_path.write_bytes(redacted) - print(f"✅ Saved to {output_path}") + + try: + # Step 1: Search for keywords in document + print(f'🔍 Searching for {len(keywords)} keyword(s) in {input_pdf.name}...') + print(f" Keywords: {', '.join(repr(k) for k in keywords)}") + + bbox_data = client.find_text_boxes(input_pdf, keywords) + + # Extract text box locations from response + text_boxes = bbox_data.get('result', {}).get('textBoxes', []) + + if not text_boxes: + print('ℹ️ No keyword matches found - copying original file') # noqa: RUF001 + # Copy original file to output if no keywords found + output_pdf.write_bytes(input_pdf.read_bytes()) + print(f'✅ Saved: {output_pdf.name}') + print(f'📂 Output: {output_pdf.absolute()}') + return + + print(f'🎯 Found {len(text_boxes)} keyword instance(s) to redact') + + # Step 2: Prepare redaction coordinates + print("🔒 Applying redactions...") + redactions = [ + {"pageIndex": box["pageIndex"], "boundingBox": box["boundingBox"]} for box in text_boxes + ] + + # Step 3: Apply redactions to document + redacted_pdf = client.redact(input_pdf, redactions) + + # Save redacted PDF + output_pdf.write_bytes(redacted_pdf) + + # Display success message + print('✅ Redaction successful!') + print(f'🔒 Redacted: {len(text_boxes)} instance(s)') + print(f'📄 Input: {input_pdf.name}') + print(f'📄 Output: {output_pdf.name}') + print(f'📂 Saved to: {output_pdf.absolute()}') + + except Exception as e: # noqa: BLE001 + print(f'❌ Redaction FAILED: {e}') + raise typer.Exit(code=1) from None + + +if __name__ == '__main__': + app() diff --git a/samples/python/requirements.txt b/samples/python/requirements.txt deleted file mode 100644 index 2b1be2a..0000000 --- a/samples/python/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -requests>=2.31.0 -python-dotenv>=1.0.0 diff --git a/samples/python/smart_redact_pii.py b/samples/python/smart_redact_pii.py index 75b45da..c3c0718 100644 --- a/samples/python/smart_redact_pii.py +++ b/samples/python/smart_redact_pii.py @@ -1,34 +1,120 @@ -#!/usr/bin/env python -"""Smart redact PII from documents.""" +#!/usr/bin/env python3 +""" +🔒 SMART PII REDACTION +====================== + +The script exemplifies a typical workflow for protecting sensitive customer information. +As a compliance officer, it's essential to review and redact +personally identifiable information (PII) from documents before sharing them with +third parties, storing them in public systems, or using them for analysis. Manual +redaction is time-consuming and error-prone, potentially missing sensitive data like +social security numbers, phone numbers, addresses, or email addresses. + +This workflow automates compliant document redaction. The script processes each PDF +file individually - for every document in the input folder, it uses AI-powered PII +detection to identify all instances of sensitive information across all pages, then +automatically applies redactions to permanently remove this data. Each processed file +is saved to the output folder with all PII securely redacted, ready for safe sharing +or archival. + +PRIVACY COMPLIANCE STANDARDS: + ✓ AI-powered PII detection (SSN, phone, email, address) + ✓ Automatic redaction (permanent removal) + ✓ Batch processing (entire folders) + +USAGE: + python smart_redact_pii.py + +EXAMPLE: + python smart_redact_pii.py ../../test_files/test-pdfs ./output +""" -import sys from pathlib import Path -from platform_api import PlatformAPIClient - -if __name__ == "__main__": - if len(sys.argv) != 3: - print("Usage: python smart_redact_pii.py ") - sys.exit(1) - - input_path = Path(sys.argv[1]) - output_path = Path(sys.argv[2]) - +from typing import Annotated + +import typer + +from api.platform_api import PlatformAPIClient +from helper_functions.document_helpers import validate_and_setup + +app = typer.Typer() + + +@app.command() +def main( + input_folder: Annotated[Path, typer.Argument(help='Input folder containing PDF documents')], + output_folder: Annotated[Path, typer.Argument(help='Output folder for redacted PDFs')], +) -> None: + """Automatically detect and redact PII (personally identifiable information) from PDFs.""" + + # Validate and setup (only process PDF files) + files = validate_and_setup(input_folder, output_folder, file_patterns=["*.pdf"]) + print(f"📋 Found {len(files)} PDF document(s) to process") + + # Initialize API client client = PlatformAPIClient() - - print(f"🔍 Detecting PII in {input_path.name}...") - pii_data = client.detect_pii(input_path) - - pii_boxes = pii_data.get('result', {}).get('PIIBoxes', []) - if not pii_boxes: - print("✅ No PII detected") - sys.exit(0) - - print(f"🎯 Found {len(pii_boxes)} PII instances") - print(f"🔒 Redacting PII...") - - redactions = [{"pageIndex": box["pageIndex"], "boundingBox": box["boundingBox"]} - for box in pii_boxes] - - redacted = client.redact(input_path, redactions) - output_path.write_bytes(redacted) - print(f"✅ Saved to {output_path}") + + # Process each document + success_count = 0 + failed_count = 0 + total_pii_count = 0 + + for i, pdf_file in enumerate(files, 1): + print(f"[{i}/{len(files)}] Processing: {pdf_file.name}") + + try: + # Step 1: Detect PII in the document + print(" 🔍 Detecting PII...") + pii_data = client.detect_pii(pdf_file) + + # Extract PII bounding boxes from response + pii_boxes = pii_data.get("result", {}).get("PIIBoxes", []) + + if not pii_boxes: + print(" ℹ️ No PII detected - copying original file") # noqa: RUF001 + + # Copy original file to output if no PII found + output_file = output_folder / pdf_file.name + output_file.write_bytes(pdf_file.read_bytes()) + + print(f" ✅ Saved: {output_file.name}") + + success_count += 1 + continue + + print(f" 🎯 Found {len(pii_boxes)} PII instance(s)") + total_pii_count += len(pii_boxes) + + # Step 2: Prepare redaction coordinates + print(" 🔒 Applying redactions...") + redactions = [ + {"pageIndex": box["pageIndex"], "boundingBox": box["boundingBox"]} + for box in pii_boxes + ] + + # Step 3: Apply redactions to document + redacted_pdf = client.redact(pdf_file, redactions) + + # Save redacted PDF + output_file = output_folder / pdf_file.name + output_file.write_bytes(redacted_pdf) + + print(f" ✅ Redacted: {output_file.name}") + success_count += 1 + + except Exception as e: # noqa: BLE001 + print(f" ❌ FAILED: {e}") + failed_count += 1 + + # Display summary + print("=" * 60) + print(f"✅ {success_count} document(s) processed") + print(f"🔒 {total_pii_count} total PII instance(s) redacted") + if failed_count > 0: + print(f"⚠️ {failed_count} document(s) FAILED - review manually!") + print(f"📂 Output: {output_folder.absolute()}") + print("=" * 60) + + +if __name__ == '__main__': + app() diff --git a/samples/python/uv.lock b/samples/python/uv.lock new file mode 100644 index 0000000..b94dd7b --- /dev/null +++ b/samples/python/uv.lock @@ -0,0 +1,503 @@ +version = 1 +revision = 2 +requires-python = ">=3.14" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, +] + +[[package]] +name = "astroid" +version = "4.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/22/97df040e15d964e592d3a180598ace67e91b7c559d8298bdb3c949dc6e42/astroid-4.0.2.tar.gz", hash = "sha256:ac8fb7ca1c08eb9afec91ccc23edbd8ac73bb22cbdd7da1d488d9fb8d6579070", size = 405714, upload-time = "2025-11-09T21:21:18.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/ac/a85b4bfb4cf53221513e27f33cc37ad158fce02ac291d18bee6b49ab477d/astroid-4.0.2-py3-none-any.whl", hash = "sha256:d7546c00a12efc32650b19a2bb66a153883185d3179ab0d4868086f807338b9b", size = 276354, upload-time = "2025-11-09T21:21:16.54Z" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[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, 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, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dill" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "isort" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[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, 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, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "nitro-platform-samples" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "reportlab" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pyenchant" }, + { name = "pylint" }, + { name = "pyright" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pydantic-settings", specifier = ">=2.0.0" }, + { name = "pyenchant", marker = "extra == 'dev'", specifier = ">=3.3.0" }, + { name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.4" }, + { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.407" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "reportlab", specifier = ">=4.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, + { name = "typer", specifier = ">=0.9.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "pillow" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, + { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, + { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, + { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, + { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, + { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, + { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, + { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, + { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, + { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + +[[package]] +name = "pyenchant" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/ad/64925c937e41be75c7067c85757b3d45b148e9111187b37693269f583156/pyenchant-3.3.0.tar.gz", hash = "sha256:825288246b5debc9436f91967650974ef0d5636458502619e322c476f1283891", size = 60696, upload-time = "2025-09-14T16:23:12.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/b0/35926bad6885fb7bc24aa7e1b45e6d86540c6c57ee4abc4fed1ef58d4ec0/pyenchant-3.3.0-py3-none-any.whl", hash = "sha256:3da00b1d01314d85aac733bb997415d7a3e875666dc81735ddcf320aa36b7a70", size = 58363, upload-time = "2025-09-14T16:23:04.297Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7f/1d7b8ad86c2a841d940df7b965fa727e052b95d539e4c563da685c25d0d2/pyenchant-3.3.0-py3-none-win32.whl", hash = "sha256:1d55e075645a6edbb3c590fb42f9e02b4d455e4affe28a2227d5cb6d4868e626", size = 37787278, upload-time = "2025-09-14T16:23:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ae/5624803b62ecb0a20248f0d28ed3f78c78746a032582a016d4b2890c7899/pyenchant-3.3.0-py3-none-win_amd64.whl", hash = "sha256:04a5bd0e022ebe2e8c6d9e498ec3d650602e264ec5486e9c6a1b7f99c9507c49", size = 37427576, upload-time = "2025-09-14T16:23:09.574Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pylint" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d2/b081da1a8930d00e3fc06352a1d449aaf815d4982319fab5d8cdb2e9ab35/pylint-4.0.4.tar.gz", hash = "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2", size = 1571735, upload-time = "2025-11-30T13:29:04.315Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/92/d40f5d937517cc489ad848fc4414ecccc7592e4686b9071e09e64f5e378e/pylint-4.0.4-py3-none-any.whl", hash = "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0", size = 536425, upload-time = "2025-11-30T13:29:02.53Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.407" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "reportlab" +version = "4.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/a7/4600cb1cfc975a06552e8927844ddcb8fd90217e9a6068f5c7aa76c3f221/reportlab-4.4.7.tar.gz", hash = "sha256:41e8287af965e5996764933f3e75e7f363c3b6f252ba172f9429e81658d7b170", size = 3714000, upload-time = "2025-12-21T11:50:11.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/bf/a29507386366ab17306b187ad247dd78e4599be9032cb5f44c940f547fc0/reportlab-4.4.7-py3-none-any.whl", hash = "sha256:8fa05cbf468e0e76745caf2029a4770276edb3c8e86a0b71e0398926baf50673", size = 1954263, upload-time = "2025-12-21T11:50:08.93Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + +[[package]] +name = "typer" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/30/ff9ede605e3bd086b4dd842499814e128500621f7951ca1e5ce84bbf61b1/typer-0.21.0.tar.gz", hash = "sha256:c87c0d2b6eee3b49c5c64649ec92425492c14488096dfbc8a0c2799b2f6f9c53", size = 106781, upload-time = "2025-12-25T09:54:53.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e4/5ebc1899d31d2b1601b32d21cfb4bba022ae6fce323d365f0448031b1660/typer-0.21.0-py3-none-any.whl", hash = "sha256:c79c01ca6b30af9fd48284058a7056ba0d3bf5cf10d0ff3d0c5b11b68c258ac6", size = 47109, upload-time = "2025-12-25T09:54:51.918Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] diff --git a/test_files/pdf-distribution/Marketing_Brochure_Product_A_Rich.docx b/test_files/pdf-distribution/Marketing_Brochure_Product_A_Rich.docx new file mode 100644 index 0000000..58cdaf6 Binary files /dev/null and b/test_files/pdf-distribution/Marketing_Brochure_Product_A_Rich.docx differ diff --git a/test_files/pdf-distribution/Marketing_Brochure_Product_B_Rich.docx b/test_files/pdf-distribution/Marketing_Brochure_Product_B_Rich.docx new file mode 100644 index 0000000..5be11a4 Binary files /dev/null and b/test_files/pdf-distribution/Marketing_Brochure_Product_B_Rich.docx differ diff --git a/test_files/pdf-distribution/Marketing_Brochure_Product_C_Rich.docx b/test_files/pdf-distribution/Marketing_Brochure_Product_C_Rich.docx new file mode 100644 index 0000000..306a031 Binary files /dev/null and b/test_files/pdf-distribution/Marketing_Brochure_Product_C_Rich.docx differ diff --git a/test_files/test-sign/README.md b/test_files/test-sign/README.md new file mode 100644 index 0000000..e1b27be --- /dev/null +++ b/test_files/test-sign/README.md @@ -0,0 +1,63 @@ +# Test Files for Employee Policy Distribution + +This folder contains test data for the `send_policies_to_employees.py` script. + +## Contents + +### Policy Documents +- `company-policies.pdf` - Sample company policy document +- `confidentiality-agreement.pdf` - Sample confidentiality agreement + +### Employee Data +- `employees.csv` - List of 3 test employees with name and email + +## Usage + +### From the samples/python directory: + +```bash +# Run the script with these test files +python send_policies_to_employees.py \ + ../../test_files/test-sign \ + ../../test_files/test-sign/employees.csv \ + ./output/test-sign-output + +# Or use separate policies folder and CSV +python send_policies_to_employees.py \ + ../../test_files/test-sign \ + ../../test_files/test-sign/employees.csv \ + ./output/newJoinersJanuary +``` + +### Expected Output Structure + +``` +output/test-sign-output/ +├── john-doe/ +│ ├── signed-policies.pdf +│ ├── envelope-info.json +│ └── envelope-id.txt +├── jane-smith/ +│ ├── signed-policies.pdf +│ ├── envelope-info.json +│ └── envelope-id.txt +└── bob-johnson/ + ├── signed-policies.pdf + ├── envelope-info.json + └── envelope-id.txt +``` + +## Notes + +- **Important**: Update the email addresses in `employees.csv` to real email addresses you can access before testing +- The script will send signature requests to these emails +- You'll need to sign the documents to complete the test +- For testing without waiting, add `--no-wait` flag + +## Test Workflow + +1. Update emails in `employees.csv` to your test email addresses +2. Run the script with the command above +3. Check your email for signature requests +4. Sign the documents through the email link +5. Script will download signed PDFs to output folders diff --git a/test_files/test-sign/company-policies.pdf b/test_files/test-sign/company-policies.pdf new file mode 100755 index 0000000..e1d49cd Binary files /dev/null and b/test_files/test-sign/company-policies.pdf differ diff --git a/test_files/test-sign/confidentiality-agreement.pdf b/test_files/test-sign/confidentiality-agreement.pdf new file mode 100755 index 0000000..46053a8 Binary files /dev/null and b/test_files/test-sign/confidentiality-agreement.pdf differ diff --git a/test_files/test-sign/employees.csv b/test_files/test-sign/employees.csv new file mode 100644 index 0000000..721501c --- /dev/null +++ b/test_files/test-sign/employees.csv @@ -0,0 +1,5 @@ +name,email +Isadora Pereira, isamap2410@gmail.com +John Doe,john.doe@company.com +Jane Smith,jane.smith@company.com +Bob Johnson,bob.johnson@company.com diff --git a/test_files/test-sign/sample-company-policies.pdf b/test_files/test-sign/sample-company-policies.pdf new file mode 100755 index 0000000..e1d49cd Binary files /dev/null and b/test_files/test-sign/sample-company-policies.pdf differ