Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,32 @@ products = client.list_products(search="fan")
identity = client.me()
```

### Upload a sheet PDF

Upload one PDF (maximum 30 MiB), then poll the returned `sheet-upload` job.
`file_name` remains the source-file identity; `name` is only a display name.

```python
with open("A-Plans.pdf", "rb") as pdf:
job = client.create_sheet_file(
"project-id",
pdf,
"A-Plans.pdf",
name="Architectural Plans",
idempotency_key="unique-upload-key",
)

while job.status in {"queued", "running"}:
job = client.get_job("project-id", job.job_id)

if job.type == "sheet-upload" and job.result and job.result.ready_for_takeoff:
client.create_job("project-id", {"type": "auto-takeoff"})
```

If `ready_for_takeoff` is false, patch the project `sheets` subcollection to
place an eligible page before auto-takeoff. Auto-takeoff is project-wide, not
limited to this upload.

## v0.6 migration notes

This SDK follows the breaking HVAKR v0.6 API:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "hvakr"
version = "0.6.0"
version = "0.6.1"
description = "Official Python SDK for the HVAKR API - HVAC load calculation and building analysis"
readme = "README.md"
license = "MIT"
Expand Down
6 changes: 5 additions & 1 deletion src/hvakr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ async def main():
APIProduct,
APIProjectCalculations,
APIReport,
APISheetUploadJobResult,
APISheetUploadPage,
Box,
DisplayUnitSystemId,
ExpandedProject,
Expand Down Expand Up @@ -65,7 +67,7 @@ async def main():
)
from hvakr.webhooks import HVAKRWebhookError, construct_webhook_event

__version__ = "0.6.0"
__version__ = "0.6.1"

__all__ = [
# Client classes
Expand Down Expand Up @@ -93,6 +95,8 @@ async def main():
# API schemas
"APIJob",
"APIJobCreate",
"APISheetUploadJobResult",
"APISheetUploadPage",
"APIMe",
"APIMeOrganization",
"APIMePlan",
Expand Down
58 changes: 56 additions & 2 deletions src/hvakr/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import logging
from collections.abc import Mapping, Sequence
from typing import Any, Literal, overload
from typing import Any, BinaryIO, Literal, overload
from urllib.parse import quote

import httpx
Expand All @@ -26,9 +26,10 @@
ProjectPost,
)

__version__ = "0.6.0"
__version__ = "0.6.1"
_LOGGER = logging.getLogger(__name__)
_WARNED_CLIENT_MESSAGES: set[str] = set()
MAX_API_SHEET_UPLOAD_BYTES = 30 * 1024 * 1024
_ProjectSubcollectionKey = Literal[
"branchTypes",
"deadlines",
Expand Down Expand Up @@ -114,6 +115,21 @@ def _write_headers(self, idempotency_key: str | None = None) -> dict[str, str]:
headers["Idempotency-Key"] = idempotency_key
return headers

def _multipart_write_headers(self, idempotency_key: str | None = None) -> dict[str, str]:
headers = {**self._get_auth_headers(), "Accept": "application/json"}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
return headers

@staticmethod
def _sheet_upload_content(file: bytes | BinaryIO) -> bytes:
content = file if isinstance(file, bytes) else file.read()
if not isinstance(content, bytes):
raise TypeError("Sheet upload file must provide bytes.")
if len(content) > MAX_API_SHEET_UPLOAD_BYTES:
raise ValueError("Sheet PDF exceeds the 30 MiB API upload limit.")
Comment on lines +126 to +130

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound file-like reads before enforcing the upload limit.

file.read() loads an arbitrarily large BinaryIO into memory before line 129 rejects it. Read at most MAX_API_SHEET_UPLOAD_BYTES + 1 bytes so oversized streams cannot exhaust process memory.

Proposed fix
-        content = file if isinstance(file, bytes) else file.read()
+        content = (
+            file
+            if isinstance(file, bytes)
+            else file.read(MAX_API_SHEET_UPLOAD_BYTES + 1)
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
content = file if isinstance(file, bytes) else file.read()
if not isinstance(content, bytes):
raise TypeError("Sheet upload file must provide bytes.")
if len(content) > MAX_API_SHEET_UPLOAD_BYTES:
raise ValueError("Sheet PDF exceeds the 30 MiB API upload limit.")
content = (
file
if isinstance(file, bytes)
else file.read(MAX_API_SHEET_UPLOAD_BYTES + 1)
)
if not isinstance(content, bytes):
raise TypeError("Sheet upload file must provide bytes.")
if len(content) > MAX_API_SHEET_UPLOAD_BYTES:
raise ValueError("Sheet PDF exceeds the 30 MiB API upload limit.")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hvakr/client.py` around lines 126 - 130, Update the file-content loading
logic near the sheet upload validation to bound file-like reads: preserve direct
bytes handling, but call read with MAX_API_SHEET_UPLOAD_BYTES + 1 for BinaryIO
inputs so the existing length check can detect oversize content without loading
it fully into memory. Keep the TypeError and ValueError behavior unchanged.

return content

@staticmethod
def _payload(data: BaseModel | Mapping[str, Any]) -> dict[str, Any]:
if isinstance(data, BaseModel):
Expand Down Expand Up @@ -323,6 +339,25 @@ def create_job(
)
return APIJob.model_validate(self._handle_response(response))

def create_sheet_file(
self,
project_id: str,
file: bytes | BinaryIO,
file_name: str,
*,
name: str | None = None,
idempotency_key: str | None = None,
) -> APIJob:
"""Upload one PDF and return its multipart-only ``sheet-upload`` job."""
content = self._sheet_upload_content(file)
response = self._http_client.post(
self._create_url(f"/projects/{self._encode_path_segment(project_id)}/sheet-files"),
headers=self._multipart_write_headers(idempotency_key),
files={"file": (file_name, content, "application/pdf")},
data={"name": name} if name is not None else None,
)
return APIJob.model_validate(self._handle_response(response))

def get_job(self, project_id: str, job_id: str) -> APIJob:
response = self._http_client.get(
self._create_url(
Expand Down Expand Up @@ -503,6 +538,25 @@ async def create_job(
)
return APIJob.model_validate(self._handle_response(response))

async def create_sheet_file(
self,
project_id: str,
file: bytes | BinaryIO,
file_name: str,
*,
name: str | None = None,
idempotency_key: str | None = None,
) -> APIJob:
"""Upload one PDF and return its multipart-only ``sheet-upload`` job."""
content = self._sheet_upload_content(file)
response = await self._http_client.post(
self._create_url(f"/projects/{self._encode_path_segment(project_id)}/sheet-files"),
headers=self._multipart_write_headers(idempotency_key),
files={"file": (file_name, content, "application/pdf")},
data={"name": name} if name is not None else None,
)
return APIJob.model_validate(self._handle_response(response))

async def get_job(self, project_id: str, job_id: str) -> APIJob:
response = await self._http_client.get(
self._create_url(
Expand Down
4 changes: 4 additions & 0 deletions src/hvakr/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
APIMeRateLimit,
APIMeUser,
APIProduct,
APISheetUploadJobResult,
APISheetUploadPage,
ProductListResponse,
ProjectListItem,
ProjectListResponse,
Expand Down Expand Up @@ -70,6 +72,8 @@
__all__ = [
"APIJob",
"APIJobCreate",
"APISheetUploadJobResult",
"APISheetUploadPage",
"APIMe",
"APIMeOrganization",
"APIMePlan",
Expand Down
34 changes: 32 additions & 2 deletions src/hvakr/schemas/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,43 @@ class APIJobCreate(BaseModel):
model_config = {"populate_by_name": True}


class APISheetUploadPage(BaseModel):
"""An actionable PDF page returned by a sheet-upload job."""

id: str
page_number: int = Field(alias="pageNumber")
sheet_number: str | None = Field(default=None, alias="sheetNumber")
sheet_type: str | None = Field(default=None, alias="sheetType")
detected_level: float | None = Field(default=None, alias="detectedLevel")
scale: float | None = None
status: Literal["queued", "running", "completed", "failed"]
error: str | None = None
placed: bool

model_config = {"populate_by_name": True}


class APISheetUploadJobResult(BaseModel):
"""Live result derived from an uploaded sheet file and its pages."""

sheet_file_id: str = Field(alias="sheetFileId")
source_file_name: str = Field(alias="sourceFileName")
name: str | None = None
ready_for_takeoff: bool | None = Field(default=None, alias="readyForTakeoff")
pages_processed: int = Field(alias="pagesProcessed")
placed_sheets: int = Field(alias="placedSheets")
pages: list[APISheetUploadPage]

model_config = {"populate_by_name": True}


class APIJob(BaseModel):
"""An asynchronous or synchronous job returned by the API."""

job_id: str = Field(alias="jobId")
type: Literal["report", "auto-group", "check", "auto-takeoff"]
type: Literal["report", "auto-group", "check", "auto-takeoff", "sheet-upload"]
status: Literal["queued", "running", "completed", "failed"]
result: dict[str, Any] | None = None
result: APISheetUploadJobResult | dict[str, Any] | None = None
error: str | None = None

model_config = {"populate_by_name": True}
Expand Down
48 changes: 46 additions & 2 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def test_auth_headers_identify_the_sdk(self) -> None:
client = HVAKRClient(access_token="my-secret-token")
assert client._get_auth_headers() == {
"Authorization": "Bearer my-secret-token",
"X-HVAKR-Client": "hvakr-python/0.6.0",
"X-HVAKR-Client": "hvakr-python/0.6.1",
}

def test_list_projects_is_paginated_and_filterable(self, httpx_mock: HTTPXMock) -> None:
Expand Down Expand Up @@ -117,7 +117,7 @@ def test_write_requests_send_idempotency_key(self, httpx_mock: HTTPXMock) -> Non
request = httpx_mock.get_requests()[0]
assert result == {"id": "new-project"}
assert request.headers["Idempotency-Key"] == "create-1"
assert request.headers["X-HVAKR-Client"] == "hvakr-python/0.6.0"
assert request.headers["X-HVAKR-Client"] == "hvakr-python/0.6.1"

def test_get_project_calculations_selects_sections(self, httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
Expand Down Expand Up @@ -166,6 +166,50 @@ def test_job_product_and_me_endpoints(self, httpx_mock: HTTPXMock) -> None:
assert products.products[0].id == "fan-1"
assert me.rate_limit.limit_per_minute == 60

def test_create_sheet_file_posts_pdf_multipart_and_parses_job(
self, httpx_mock: HTTPXMock
) -> None:
httpx_mock.add_response(
url="https://api.hvakr.com/v0/projects/project-123/sheet-files",
status_code=202,
json={
"jobId": "job-sheet-1",
"type": "sheet-upload",
"status": "queued",
"result": {
"sheetFileId": "sheet-file-1",
"sourceFileName": "A-Plans.pdf",
"name": "Architectural Plans",
"pagesProcessed": 0,
"placedSheets": 0,
"pages": [],
},
},
)

with HVAKRClient(access_token="test-token") as client:
job = client.create_sheet_file(
"project-123",
b"%PDF-1.7",
"A-Plans.pdf",
name="Architectural Plans",
idempotency_key="sheet-upload-1",
)

request = httpx_mock.get_requests()[0]
assert request.headers["Idempotency-Key"] == "sheet-upload-1"
assert request.headers["Content-Type"].startswith("multipart/form-data; boundary=")
assert b'filename="A-Plans.pdf"' in request.content
assert b"\r\nArchitectural Plans\r\n" in request.content
assert job.type == "sheet-upload"
assert job.result is not None
assert job.result.sheet_file_id == "sheet-file-1"

def test_create_sheet_file_preflights_30_mib_limit(self) -> None:
with HVAKRClient(access_token="test-token") as client:
with pytest.raises(ValueError, match="30 MiB"):
client.create_sheet_file("project-123", b"x" * (30 * 1024 * 1024 + 1), "large.pdf")

def test_error_response_exposes_status_and_metadata(self, httpx_mock: HTTPXMock) -> None:
httpx_mock.add_response(
url="https://api.hvakr.com/v0/projects",
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.