From a6617ba86b0b31aed337cf285c34793c25d697ad Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 20 Oct 2026 16:40:45 -0600 Subject: [PATCH 1/3] Raise APIError for OCR provider processing failures --- agent-notes/modernize/task6-ocrspace.md | 23 +++++++++ ocrspace.py | 10 +++- test_ocrspace.py | 65 +++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 agent-notes/modernize/task6-ocrspace.md create mode 100644 test_ocrspace.py diff --git a/agent-notes/modernize/task6-ocrspace.md b/agent-notes/modernize/task6-ocrspace.md new file mode 100644 index 0000000..df6ceed --- /dev/null +++ b/agent-notes/modernize/task6-ocrspace.md @@ -0,0 +1,23 @@ +# Report provider processing errors through APIError + +## Goal + +Handle documented top-level OCR error responses before indexing ParsedResults. Smallest slice: read the JSON payload once and check IsErroredOnProcessing in each existing method (approximately eight source lines), a local mocked response regression file, and this note. No endpoint, credentials, upload, OCR engine, dependency or public return-shape change. + +## State + +- Fresh default/base: `3d13e393a7d050613467b9c1fea9c25c4c89704a`; isolated branch modernize/task6-ocrspace in task-6/worktrees/ocrspace. Canonical git-conversions checkout and same-name non-Git user directory remain untouched. +- Read complete ocrspace.py/setup.py (version0.1; requests dependency), actual Chicken Nugget OCR initializer and interface.py:1253-1296 consumer/error handling. +- No local guidance, notes, existing tests, hosted CI, lock, hooks configuration or documented full-check command exists at base. +- Primary schema: https://ocr.space/ocrapi documents top-level IsErroredOnProcessing/ErrorMessage separately from per-page ParsedResults. Provider support evidence https://forum.ui.vision/t/internal-processing-error/5180 contains an actual processing-error response without ParsedResults. Tests use that response shape, not a live service. +- All HTTP calls are patched; file inputs are local BytesIO fixtures. No file upload, URL retrieval, API-key validation or OCR request is performed. +- Red: `python -B -m unittest -v test_ocrspace.py` ran the entire sole suite on CPython3.13.6. Both documented provider-error subcases raised KeyError('ParsedResults') instead of APIError; existing success-shape and per-page-error characterization cases passed (3 tests,2 errors). +- Green: the same complete sole-suite command passed all3 tests in0.002s after the two method checks. No dependency resolver, build or live API operation was run. + +## Decisions and why + +Use the existing APIError with the provider's original ErrorMessage before page indexing. Keep existing per-page errors and successful URL single-result/file list returns. Do not introduce a shared response abstraction or change partial-page handling beyond the provider's explicit error flag. + +## Next step + +Inspect and commit only ocrspace.py, test_ocrspace.py and this note, then send the exact patch for coordinator review before publication. Focused mocked tests do not claim live OCR/provider interoperability. diff --git a/ocrspace.py b/ocrspace.py index 40db88a..2aee7b1 100644 --- a/ocrspace.py +++ b/ocrspace.py @@ -18,7 +18,10 @@ def OCR_URL(self, url, overlay=False): } response = requests.post(self.url, data=payload) response.raise_for_status() - result = response.json()['ParsedResults'][0] + response_data = response.json() + if response_data.get('IsErroredOnProcessing'): + raise APIError(response_data.get('ErrorMessage')) + result = response_data['ParsedResults'][0] if result['ErrorMessage']: raise APIError(result['ErrorMessage']) return result @@ -30,7 +33,10 @@ def OCR_file(self, fileobj, overlay=False): } response = requests.post(self.url, data=payload, files={'file': fileobj}) response.raise_for_status() - results = response.json()['ParsedResults'] + response_data = response.json() + if response_data.get('IsErroredOnProcessing'): + raise APIError(response_data.get('ErrorMessage')) + results = response_data['ParsedResults'] if results[0]['ErrorMessage']: raise APIError(results[0]['ErrorMessage']) return results diff --git a/test_ocrspace.py b/test_ocrspace.py new file mode 100644 index 0000000..a153777 --- /dev/null +++ b/test_ocrspace.py @@ -0,0 +1,65 @@ +"""Check provider response contracts with mocked HTTP and local fixtures.""" + +from io import BytesIO +import unittest +from unittest.mock import Mock, patch + +import ocrspace + + +class OCRSpaceResponseTests(unittest.TestCase): + def setUp(self): + self.api = ocrspace.OCRSpaceAPI("example-key") + self.response = Mock() + post = patch.object(ocrspace.requests, "post", return_value=self.response) + post.start() + self.addCleanup(post.stop) + + def test_provider_error_without_page_results_raises_api_error(self): + # Provider-documented error schema, also reported in its support forum: + # https://forum.ui.vision/t/internal-processing-error/5180 + messages = ["Some internal processing error occurred."] + self.response.json.return_value = { + "OCRExitCode": 99, + "IsErroredOnProcessing": True, + "ErrorMessage": messages, + "ErrorDetails": "", + } + with BytesIO(b"local fixture") as image: + calls = ( + (self.api.OCR_URL, "https://example.test/image.png"), + (self.api.OCR_file, image), + ) + for method, argument in calls: + with self.subTest(method=method.__name__): + with self.assertRaises(ocrspace.APIError) as caught: + method(argument) + self.assertEqual(caught.exception.args[0], messages) + + def test_success_retains_url_and_file_return_shapes(self): + pages = [ + {"ErrorMessage": "", "ParsedText": "first page"}, + {"ErrorMessage": "", "ParsedText": "second page"}, + ] + self.response.json.return_value = { + "IsErroredOnProcessing": False, "ParsedResults": pages + } + + self.assertEqual(self.api.OCR_URL("https://example.test/image.png"), pages[0]) + with BytesIO(b"local fixture") as image: + self.assertEqual(self.api.OCR_file(image), pages) + + def test_page_error_still_raises_api_error(self): + self.response.json.return_value = { + "IsErroredOnProcessing": False, + "ParsedResults": [{"ErrorMessage": "page parse failed"}], + } + with self.assertRaises(ocrspace.APIError): + self.api.OCR_URL("https://example.test/image.png") + with BytesIO(b"local fixture") as image: + with self.assertRaises(ocrspace.APIError): + self.api.OCR_file(image) + + +if __name__ == "__main__": + unittest.main() From 66ce14cc8aaa5e0c476ce46a35ff818f00f87a83 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 20 Oct 2026 16:46:33 -0600 Subject: [PATCH 2/3] Preserve partial OCR page results when reporting top-level errors --- agent-notes/modernize/task6-ocrspace.md | 8 +++-- ocrspace.py | 4 +-- test_ocrspace.py | 39 ++++++++++++++++++------- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/agent-notes/modernize/task6-ocrspace.md b/agent-notes/modernize/task6-ocrspace.md index df6ceed..0565ed3 100644 --- a/agent-notes/modernize/task6-ocrspace.md +++ b/agent-notes/modernize/task6-ocrspace.md @@ -2,7 +2,7 @@ ## Goal -Handle documented top-level OCR error responses before indexing ParsedResults. Smallest slice: read the JSON payload once and check IsErroredOnProcessing in each existing method (approximately eight source lines), a local mocked response regression file, and this note. No endpoint, credentials, upload, OCR engine, dependency or public return-shape change. +Handle documented top-level OCR error responses with absent or empty ParsedResults before indexing that collection. Smallest slice: read the JSON payload once and check IsErroredOnProcessing only when page results are absent/empty in each existing method (approximately eight source lines), a local mocked response regression file, and this note. No endpoint, credentials, upload, OCR engine, dependency or public return-shape change. ## State @@ -13,11 +13,13 @@ Handle documented top-level OCR error responses before indexing ParsedResults. S - All HTTP calls are patched; file inputs are local BytesIO fixtures. No file upload, URL retrieval, API-key validation or OCR request is performed. - Red: `python -B -m unittest -v test_ocrspace.py` ran the entire sole suite on CPython3.13.6. Both documented provider-error subcases raised KeyError('ParsedResults') instead of APIError; existing success-shape and per-page-error characterization cases passed (3 tests,2 errors). - Green: the same complete sole-suite command passed all3 tests in0.002s after the two method checks. No dependency resolver, build or live API operation was run. +- Revision red: the new partial-result characterization failed with APIError under the initial unconditional guard. Read-only inspection after an editing-tool context error confirmed both narrowed guards and the new partial/empty-result tests were already applied; they were not applied again. Run the revised four-test sole suite next. +- Revision green: the complete sole suite now passes all4 tests in0.002s, covering missing/empty error page collections for both methods and preserving successful/partial-result/per-page-error contracts. ## Decisions and why -Use the existing APIError with the provider's original ErrorMessage before page indexing. Keep existing per-page errors and successful URL single-result/file list returns. Do not introduce a shared response abstraction or change partial-page handling beyond the provider's explicit error flag. +Use the existing APIError with the provider's original ErrorMessage only when an explicit processing error has no page results. A processing-error flag can accompany partial results; checking it unconditionally would replace the existing page-result contract. Preserve that path, including successful first-page URL results and file lists containing later page errors. Do not introduce a shared response abstraction or broaden per-page error policy. ## Next step -Inspect and commit only ocrspace.py, test_ocrspace.py and this note, then send the exact patch for coordinator review before publication. Focused mocked tests do not claim live OCR/provider interoperability. +Coordinator review of initial commit a6617ba found the unconditional flag check changed partial-result behavior. Publication was not authorized. The requested characterization is red/green and both guards are narrowed to absent/empty page results. Commit this revision and return the complete base-to-head diff for review before publication. Focused mocked tests do not claim live OCR/provider interoperability. diff --git a/ocrspace.py b/ocrspace.py index 2aee7b1..585c29a 100644 --- a/ocrspace.py +++ b/ocrspace.py @@ -19,7 +19,7 @@ def OCR_URL(self, url, overlay=False): response = requests.post(self.url, data=payload) response.raise_for_status() response_data = response.json() - if response_data.get('IsErroredOnProcessing'): + if response_data.get('IsErroredOnProcessing') and not response_data.get('ParsedResults'): raise APIError(response_data.get('ErrorMessage')) result = response_data['ParsedResults'][0] if result['ErrorMessage']: @@ -34,7 +34,7 @@ def OCR_file(self, fileobj, overlay=False): response = requests.post(self.url, data=payload, files={'file': fileobj}) response.raise_for_status() response_data = response.json() - if response_data.get('IsErroredOnProcessing'): + if response_data.get('IsErroredOnProcessing') and not response_data.get('ParsedResults'): raise APIError(response_data.get('ErrorMessage')) results = response_data['ParsedResults'] if results[0]['ErrorMessage']: diff --git a/test_ocrspace.py b/test_ocrspace.py index a153777..498c4c2 100644 --- a/test_ocrspace.py +++ b/test_ocrspace.py @@ -25,16 +25,19 @@ def test_provider_error_without_page_results_raises_api_error(self): "ErrorMessage": messages, "ErrorDetails": "", } - with BytesIO(b"local fixture") as image: - calls = ( - (self.api.OCR_URL, "https://example.test/image.png"), - (self.api.OCR_file, image), - ) - for method, argument in calls: - with self.subTest(method=method.__name__): - with self.assertRaises(ocrspace.APIError) as caught: - method(argument) - self.assertEqual(caught.exception.args[0], messages) + for empty_results in (False, True): + if empty_results: + self.response.json.return_value["ParsedResults"] = [] + with BytesIO(b"local fixture") as image: + calls = ( + (self.api.OCR_URL, "https://example.test/image.png"), + (self.api.OCR_file, image), + ) + for method, argument in calls: + with self.subTest(method=method.__name__, empty_results=empty_results): + with self.assertRaises(ocrspace.APIError) as caught: + method(argument) + self.assertEqual(caught.exception.args[0], messages) def test_success_retains_url_and_file_return_shapes(self): pages = [ @@ -49,6 +52,22 @@ def test_success_retains_url_and_file_return_shapes(self): with BytesIO(b"local fixture") as image: self.assertEqual(self.api.OCR_file(image), pages) + def test_partial_results_keep_existing_page_result_contract(self): + pages = [ + {"ErrorMessage": "", "ParsedText": "first page"}, + {"ErrorMessage": "page parse failed", "ParsedText": None}, + ] + self.response.json.return_value = { + "OCRExitCode": 2, + "IsErroredOnProcessing": True, + "ErrorMessage": ["A page could not be parsed"], + "ParsedResults": pages, + } + + self.assertEqual(self.api.OCR_URL("https://example.test/image.png"), pages[0]) + with BytesIO(b"local fixture") as image: + self.assertEqual(self.api.OCR_file(image), pages) + def test_page_error_still_raises_api_error(self): self.response.json.return_value = { "IsErroredOnProcessing": False, From 1256a3eddd9ad90cc2bf0c66fae719e3b85a4041 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Tue, 20 Oct 2026 17:42:25 -0600 Subject: [PATCH 3/3] Run OCR response tests and package build in CI --- .github/workflows/ci.yml | 36 +++++++++++++++++++++++++ README.md | 22 +++++++++++++++ agent-notes/repair-task6-ocrspace-ci.md | 27 +++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 README.md create mode 100644 agent-notes/repair-task6-ocrspace-ci.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3ab60bc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: Offline tests and build + +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-build: + name: OCR response contracts and package build + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + with: + version: "0.13.4" + python-version: "3.13" + enable-cache: false + - name: Run complete mocked HTTP suite + run: uv run --isolated --no-project --with requests python -B -m unittest discover -v + - name: Build wheel and source distribution + run: uv build + - name: Require both package artifacts + run: | + test -s dist/ocrspace-0.1-py3-none-any.whl + test -s dist/ocrspace-0.1.tar.gz diff --git a/README.md b/README.md new file mode 100644 index 0000000..1bec122 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# OCRSpace + +## Development + +Run the complete offline response-contract suite from the repository root: + +```sh +uv run --isolated --no-project --with requests python -B -m unittest discover -v +``` + +All HTTP calls are mocked and image inputs are in-memory fixtures. The tests +exercise the real wrapper without uploading files, retrieving URLs or validating +service credentials. + +Build the existing setuptools package without publishing it: + +```sh +uv build +``` + +CI runs the complete suite and build on Linux with CPython 3.13, then requires +both the wheel and source distribution. diff --git a/agent-notes/repair-task6-ocrspace-ci.md b/agent-notes/repair-task6-ocrspace-ci.md new file mode 100644 index 0000000..8d7d662 --- /dev/null +++ b/agent-notes/repair-task6-ocrspace-ci.md @@ -0,0 +1,27 @@ +# Host the existing offline response contracts and package build + +## Goal + +Add minimal hosted full-test/build coverage to feature PR #1. No source, dependency, provider policy or runtime/support changes. Approximately 35 workflow lines plus command documentation and this living note. + +## State + +- Verified origin git@github.com:accessibleapps/ocrspace.git; fresh master 3d13e393a7d050613467b9c1fea9c25c4c89704a contains only ocrspace.py/setup.py, with no tests/workflow. +- Original local and published feature heads both 66ce14cc8aaa5e0c476ce46a35ff818f00f87a83. Named backup refs backup/task6-ocrspace-local-before-ci and backup/task6-ocrspace-published-before-ci retain both. Canonical and original feature worktrees clean and unchanged. +- Isolated task-root worktrees/ocrspace-ci on repair/task6-ocrspace-ci starts at the published feature tip. Actual existing tests live only on that feature; no empty baseline prerequisite is appropriate. +- Read global instructions, repair prompts, repository-ci, complete source/setup.py/test and original branch note. No repository/nested guidance/.agents skills found. Actions enabled verified read-only; no settings changed. + +## Decisions and why + +- Original full four-method unittest suite patches requests.post and uses BytesIO fixture inputs. It exercises actual wrapper behavior for successful/partial pages, missing/empty top-level errors, and existing page errors without any real HTTP operation or credential. +- Use isolated Requests environment from existing setup.py's sole declared runtime dependency. No new lock, manifest version, major dependency update or mock-test rewrite. +- Coordinator explicitly settled narrow build authority: uv build the existing setuptools setup.py package, require exact wheel/source artifacts. This is a nonpublishing package gate, no new build system. +- One Linux/Python 3.13 job on PR/master push, read-only permission, no persisted checkout credentials, no cache/secrets, timeout and concurrency. Immutable action pins and uv 0.13.4 verified from official release metadata/manifests during this session. + +## Surprises + +Missing CI is a verification gap, not a demonstrated default source/test failure. No new behavioral red regression claimed; no live provider checks. + +## Next step + +Final new YAML passed actionlint 1.7.12 syntax/expression audit and zizmor 1.31.1 --offline --pedantic with zero findings; optional actionlint shellcheck/pyflakes integrations disabled. Complete added workflow/README/note inspected; source/setup.py/test unchanged from original feature. Commit named files and return exact head for coordinator review before any existing-feature leased publication. Prior full 4-test green results are in the original feature note; no unnecessary local full rerun. Hosted final-head test/build execution awaits publication authority. Actual Claude review is separately coordinated. No push/merge/rebase/settings/release action here.