diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..436187b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,67 @@ +name: release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate the tag + run: | + tag="${GITHUB_REF_NAME}" + echo "Releasing tag: $tag" + if ! echo "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then + echo "::error::Tag '$tag' is not vX.Y.Z (optionally -prerelease). Not releasing." + exit 1 + fi + if ! git branch -r --contains "$tag" | grep -q 'origin/master'; then + echo "::error::Tag '$tag' is not on master. Release only from master." + exit 1 + fi + prev=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | grep -v "^${tag}$" | head -1) + if [ -n "$prev" ]; then + newest=$(printf '%s\n%s\n' "$prev" "$tag" | sort -V | tail -1) + if [ "$newest" != "$tag" ]; then + echo "::error::Tag '$tag' is not newer than the last release '$prev'. Tags must go in order." + exit 1 + fi + echo "Previous release: $prev" + fi + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install + run: | + python -m pip install --upgrade pip build twine + pip install -e ".[dev]" + + - name: Test + run: python -m pytest tests/test_request_options_unit.py tests/test_files_api_unit.py tests/test_webhooks.py + + - name: Build + run: | + rm -rf dist build ./*.egg-info + python -m build + python -m twine check dist/* + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} + + - name: Create GitHub release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release create "$GITHUB_REF_NAME" dist/* --title "$GITHUB_REF_NAME" --generate-notes diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..dc2ea60 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: test + +on: + pull_request: + push: + branches: [master] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.8', '3.12'] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Test + run: python -m pytest tests/test_request_options_unit.py tests/test_files_api_unit.py tests/test_webhooks.py diff --git a/changelog.md b/changelog.md index c310a65..c963bda 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # mpxpy changelog +## September 22, 2026 + +- Automate PyPI releases. The package version is now derived from the git tag via setuptools-scm, so there is no version to bump by hand. Pushing a `vX.Y.Z` tag from master runs `.github/workflows/release.yml`, which builds the sdist and wheel, publishes to PyPI, and creates a published (non-draft) GitHub release. The tag must be valid semver, sit on master, and be newer than the last release, or the run fails before anything is published. +- Add `convert_to_xlsx` to `pdf_new` and `conversion_new`, plus `to_xlsx_file`/`to_xlsx_bytes` on `Pdf` and `Conversion`, giving xlsx the same convenience flag as the other formats +- Add `include_hyperlinks` to `pdf_new` to extract PDF link annotations into per-line `links` data +- Add `convert_to_mmd_overlay_pdf` to `pdf_new` for the new `mmd.overlay.pdf` format (your submitted PDF returned with an invisible, screen-reader-ready text layer, PDF/UA tagged by default; PDF input only), plus `to_mmd_overlay_pdf_file`/`to_mmd_overlay_pdf_bytes` on `Pdf` and `File`. Format options go through `conversion_options["mmd.overlay.pdf"]` + ## August 18, 2026 - Add webhooks support diff --git a/mpxpy/conversion.py b/mpxpy/conversion.py index e11dfad..8564959 100644 --- a/mpxpy/conversion.py +++ b/mpxpy/conversion.py @@ -27,6 +27,7 @@ class Conversion: convert_to_mmd_zip: Optional boolean to automatically convert your result to mmd.zip convert_to_pptx: Optional boolean to automatically convert your result to pptx convert_to_html_zip: Optional boolean to automatically convert your result to html.zip + convert_to_xlsx: Optional boolean to automatically convert your result to xlsx """ def __init__( self, @@ -42,6 +43,7 @@ def __init__( convert_to_mmd_zip: Optional[bool] = False, convert_to_pptx: Optional[bool] = False, convert_to_html_zip: Optional[bool] = False, + convert_to_xlsx: Optional[bool] = False, request_options: Optional[Dict[str, Any]] = None, ): """Initialize a Conversion instance. @@ -59,6 +61,7 @@ def __init__( convert_to_mmd_zip: Optional boolean to automatically convert your result to mmd.zip convert_to_pptx: Optional boolean to automatically convert your result to pptx convert_to_html_zip: Optional boolean to automatically convert your result to html.zip + convert_to_xlsx: Optional boolean to automatically convert your result to xlsx Raises: ValidationError: If auth is not provided or conversion_id is empty. @@ -81,6 +84,7 @@ def __init__( self.convert_to_mmd_zip = convert_to_mmd_zip self.convert_to_pptx = convert_to_pptx self.convert_to_html_zip = convert_to_html_zip + self.convert_to_xlsx = convert_to_xlsx self.request_options = request_options or {} def wait_until_complete(self, timeout: int=60): @@ -476,4 +480,29 @@ def to_html_zip_bytes(self) -> bytes: Raises: ConversionIncompleteError: If the conversion is not complete """ - return self.bytes_result(conversion_format='html.zip') \ No newline at end of file + return self.bytes_result(conversion_format='html.zip') + + def to_xlsx_file(self, path: str) -> str: + """Save the processed conversion result to an XLSX file. + + Args: + path: The local file path where the XLSX output will be saved + + Returns: + output_path: The path of the saved XLSX file + + Raises: + ConversionIncompleteError: If the conversion is not complete + """ + return self.save_file(path=path, conversion_format='xlsx') + + def to_xlsx_bytes(self) -> bytes: + """Get the processed conversion result in XLSX format as bytes. + + Returns: + bytes: The binary content of the XLSX result + + Raises: + ConversionIncompleteError: If the conversion is not complete + """ + return self.bytes_result(conversion_format='xlsx') \ No newline at end of file diff --git a/mpxpy/file.py b/mpxpy/file.py index a82832b..1fbaa5f 100644 --- a/mpxpy/file.py +++ b/mpxpy/file.py @@ -410,6 +410,10 @@ def to_html_zip_bytes(self) -> bytes: """Get the processed file result as html.zip bytes.""" return self.bytes_result(conversion_format='html.zip') + def to_mmd_overlay_pdf_bytes(self) -> bytes: + """Get the accessible overlay PDF (your submitted PDF with an invisible text layer) as bytes.""" + return self.bytes_result(conversion_format='mmd.overlay.pdf') + def to_jpg_bytes(self) -> bytes: """Get the processed file result as JPG bytes.""" return self.bytes_result(conversion_format='jpg') @@ -444,6 +448,10 @@ def to_xlsx_file(self, path: str) -> str: """Save the processed file result to an XLSX file at a local path.""" return self.save_file(path=path, conversion_format='xlsx') + def to_mmd_overlay_pdf_file(self, path: str) -> str: + """Save the accessible overlay PDF (your submitted PDF with an invisible text layer) to a local path.""" + return self.save_file(path=path, conversion_format='mmd.overlay.pdf') + def to_pptx_file(self, path: str) -> str: """Save the processed file result to a PPTX file at a local path.""" return self.save_file(path=path, conversion_format='pptx') diff --git a/mpxpy/mathpix_client.py b/mpxpy/mathpix_client.py index 0a1bc83..cd75119 100644 --- a/mpxpy/mathpix_client.py +++ b/mpxpy/mathpix_client.py @@ -160,7 +160,8 @@ def _reject_reserved_extra_options( 'remove_section_numbering', 'preserve_section_numbering', 'enable_tables_fallback', 'fullwidth_punctuation', 'conversion_formats', 'file_batch_id', 'callback_url', 'callback_headers', 'callback_events', 'disable_itemize', - 'disable_lstlisting', 'include_page_info', 'include_page_breaks', 'conversion_options', + 'disable_lstlisting', 'include_page_info', 'include_page_breaks', 'include_hyperlinks', + 'conversion_options', } _CONVERSION_REQUEST_OPTION_KEYS: Set[str] = {'mmd', 'formats', 'conversion_options'} @@ -450,6 +451,8 @@ def pdf_new( convert_to_mmd_zip: Optional[bool] = False, convert_to_pptx: Optional[bool] = False, convert_to_html_zip: Optional[bool] = False, + convert_to_xlsx: Optional[bool] = False, + convert_to_mmd_overlay_pdf: Optional[bool] = False, improve_mathpix: Optional[bool] = True, file_batch_id: Optional[str] = None, callback_url: Optional[str] = None, @@ -459,6 +462,7 @@ def pdf_new( disable_lstlisting: Optional[bool] = None, include_page_info: Optional[bool] = None, include_page_breaks: Optional[bool] = None, + include_hyperlinks: Optional[bool] = None, conversion_options: Optional[Dict[str, Any]] = None, extra_options: Optional[Dict[str, Any]] = None, ) -> Pdf: @@ -496,6 +500,8 @@ def pdf_new( convert_to_mmd_zip: Optional boolean to automatically convert your result to mmd.zip convert_to_pptx: Optional boolean to automatically convert your result to pptx convert_to_html_zip: Optional boolean to automatically convert your result to html.zip + convert_to_xlsx: Optional boolean to automatically convert your result to xlsx + convert_to_mmd_overlay_pdf: Optional boolean to return your submitted PDF with an invisible, screen-reader-ready text layer added (PDF/UA tagged by default; PDF input only). Configure via conversion_options["mmd.overlay.pdf"] improve_mathpix: Optional boolean to enable Mathpix to retain user output. Default is true file_batch_id: Optional batch ID to associate this file with. callback_url: Optional HTTPS URL to receive this request's webhook deliveries. A request without one is not notified at all @@ -505,6 +511,7 @@ def pdf_new( disable_lstlisting: Optional boolean to disable the lstlisting environment for code blocks include_page_info: Optional boolean to include page info in the output include_page_breaks: Optional boolean to include page break markers in the output + include_hyperlinks: Optional boolean to extract PDF link annotations and attach them to line data (adds a per-line "links" field) conversion_options: Optional dict of per-format conversion options (see https://docs.mathpix.com/reference/shared-types#conversion-options) extra_options: Optional dict of unmodeled request options. Modeled request fields are rejected; values are validated server-side @@ -588,6 +595,8 @@ def pdf_new( options["include_page_info"] = include_page_info if include_page_breaks is not None: options["include_page_breaks"] = include_page_breaks + if include_hyperlinks is not None: + options["include_hyperlinks"] = include_hyperlinks if conversion_options is not None: options["conversion_options"] = conversion_options if file_batch_id: @@ -613,6 +622,10 @@ def pdf_new( options["conversion_formats"]['mmd.zip'] = True if convert_to_html_zip: options["conversion_formats"]['html.zip'] = True + if convert_to_xlsx: + options["conversion_formats"]['xlsx'] = True + if convert_to_mmd_overlay_pdf: + options["conversion_formats"]['mmd.overlay.pdf'] = True if extra_options: options.update(extra_options) data = { @@ -647,6 +660,8 @@ def pdf_new( convert_to_mmd_zip=convert_to_mmd_zip, convert_to_pptx=convert_to_pptx, convert_to_html_zip=convert_to_html_zip, + convert_to_xlsx=convert_to_xlsx, + convert_to_mmd_overlay_pdf=convert_to_mmd_overlay_pdf, improve_mathpix=improve_mathpix, file_batch_id=file_batch_id, callback_url=callback_url, @@ -682,6 +697,8 @@ def pdf_new( convert_to_mmd_zip=convert_to_mmd_zip, convert_to_pptx=convert_to_pptx, convert_to_html_zip=convert_to_html_zip, + convert_to_xlsx=convert_to_xlsx, + convert_to_mmd_overlay_pdf=convert_to_mmd_overlay_pdf, improve_mathpix=improve_mathpix, file_batch_id=file_batch_id, callback_url=callback_url, @@ -765,6 +782,7 @@ def conversion_new( convert_to_mmd_zip: Optional[bool] = False, convert_to_pptx: Optional[bool] = False, convert_to_html_zip: Optional[bool] = False, + convert_to_xlsx: Optional[bool] = False, conversion_options: Optional[Dict[str, Any]] = None, extra_options: Optional[Dict[str, Any]] = None, ): @@ -782,6 +800,7 @@ def conversion_new( convert_to_mmd_zip: Optional boolean to automatically convert your result to mmd.zip convert_to_pptx: Optional boolean to automatically convert your result to pptx convert_to_html_zip: Optional boolean to automatically convert your result to html.zip + convert_to_xlsx: Optional boolean to automatically convert your result to xlsx conversion_options: Optional dict of per-format conversion options (see https://docs.mathpix.com/reference/shared-types#conversion-options) extra_options: Optional dict of unmodeled request options. Modeled request fields are rejected; values are validated server-side @@ -818,6 +837,8 @@ def conversion_new( options["formats"]['mmd.zip'] = True if convert_to_html_zip: options["formats"]['html.zip'] = True + if convert_to_xlsx: + options["formats"]['xlsx'] = True if conversion_options is not None: options["conversion_options"] = conversion_options if extra_options: @@ -847,6 +868,7 @@ def conversion_new( convert_to_mmd_zip=convert_to_mmd_zip, convert_to_pptx=convert_to_pptx, convert_to_html_zip=convert_to_html_zip, + convert_to_xlsx=convert_to_xlsx, request_options=self.request_options, ) except Exception as e: diff --git a/mpxpy/pdf.py b/mpxpy/pdf.py index 97d5bd1..39c94ef 100644 --- a/mpxpy/pdf.py +++ b/mpxpy/pdf.py @@ -30,6 +30,8 @@ class Pdf: convert_to_mmd_zip: Optional boolean to automatically convert your result to mmd.zip convert_to_pptx: Optional boolean to automatically convert your result to pptx convert_to_html_zip: Optional boolean to automatically convert your result to html.zip + convert_to_xlsx: Optional boolean to automatically convert your result to xlsx + convert_to_mmd_overlay_pdf: Optional boolean to return your submitted PDF with an invisible, screen-reader-ready text layer added improve_mathpix: Optional boolean to enable Mathpix to retain user output. Default is true file_batch_id: Optional batch ID to associate this file with. (Not yet enabled) callback_url: Optional HTTPS URL to receive this request's webhook deliveries. @@ -52,6 +54,8 @@ def __init__( convert_to_mmd_zip: Optional[bool] = False, convert_to_pptx: Optional[bool] = False, convert_to_html_zip: Optional[bool] = False, + convert_to_xlsx: Optional[bool] = False, + convert_to_mmd_overlay_pdf: Optional[bool] = False, improve_mathpix: Optional[bool] = False, file_batch_id: Optional[str] = None, callback_url: Optional[str] = None, @@ -76,6 +80,8 @@ def __init__( convert_to_mmd_zip: Optional boolean to automatically convert your result to mmd.zip convert_to_pptx: Optional boolean to automatically convert your result to pptx convert_to_html_zip: Optional boolean to automatically convert your result to html.zip + convert_to_xlsx: Optional boolean to automatically convert your result to xlsx + convert_to_mmd_overlay_pdf: Optional boolean to return your submitted PDF with an invisible, screen-reader-ready text layer added improve_mathpix: Optional boolean to enable Mathpix to retain user output. Default is true file_batch_id: Optional batch ID to associate this file with. (Not yet enabled) callback_url: Optional HTTPS URL to receive this request's webhook deliveries. @@ -105,6 +111,8 @@ def __init__( self.convert_to_mmd_zip = convert_to_mmd_zip self.convert_to_pptx = convert_to_pptx self.convert_to_html_zip = convert_to_html_zip + self.convert_to_xlsx = convert_to_xlsx + self.convert_to_mmd_overlay_pdf = convert_to_mmd_overlay_pdf self.improve_mathpix=improve_mathpix self.file_batch_id = file_batch_id self.callback_url = callback_url @@ -591,3 +599,53 @@ def to_html_zip_bytes(self) -> bytes: ConversionIncompleteError: If the conversion is not complete """ return self.bytes_result(conversion_format='html.zip') + + def to_xlsx_file(self, path: str) -> str: + """Save the processed conversion result to an XLSX file. + + Args: + path: The local file path where the XLSX output will be saved + + Returns: + output_path: The path of the saved XLSX file + + Raises: + ConversionIncompleteError: If the conversion is not complete + """ + return self.save_file(path=path, conversion_format='xlsx') + + def to_xlsx_bytes(self) -> bytes: + """Get the processed conversion result in XLSX format as bytes. + + Returns: + bytes: The binary content of the XLSX result + + Raises: + ConversionIncompleteError: If the conversion is not complete + """ + return self.bytes_result(conversion_format='xlsx') + + def to_mmd_overlay_pdf_file(self, path: str) -> str: + """Save the accessible overlay PDF (your submitted PDF with an invisible text layer) to a local path. + + Args: + path: The local file path where the overlay PDF will be saved + + Returns: + output_path: The path of the saved PDF file + + Raises: + ConversionIncompleteError: If the conversion is not complete + """ + return self.save_file(path=path, conversion_format='mmd.overlay.pdf') + + def to_mmd_overlay_pdf_bytes(self) -> bytes: + """Get the accessible overlay PDF (your submitted PDF with an invisible text layer) as bytes. + + Returns: + bytes: The binary content of the overlay PDF + + Raises: + ConversionIncompleteError: If the conversion is not complete + """ + return self.bytes_result(conversion_format='mmd.overlay.pdf') diff --git a/pyproject.toml b/pyproject.toml index c4201bc..8521f45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ # pyproject.toml [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=61.0", "wheel", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" [project] name = "mpxpy" -version = "0.0.22" +dynamic = ["version"] description = "Official Mathpix client for Python" readme = "README.md" requires-python = ">=3.8" @@ -42,6 +42,8 @@ Repository = "https://github.com/Mathpix/mpxpy" "Bug Tracker" = "https://github.com/Mathpix/mpxpy/issues" [tool.setuptools.packages.find] exclude = ["tests*"] +[tool.setuptools_scm] +fallback_version = "0.0.0" [project.optional-dependencies] dev = [ "pytest>=7.0.0" diff --git a/tests/test_request_options_unit.py b/tests/test_request_options_unit.py index 1295ad0..5f0e9ac 100644 --- a/tests/test_request_options_unit.py +++ b/tests/test_request_options_unit.py @@ -110,3 +110,36 @@ def test_conversion_new_rejects_modeled_extra_options(client: MathpixClient) -> convert_to_docx=True, extra_options={key: 'override'}, ) + + +def test_pdf_new_sends_convert_to_xlsx_and_include_hyperlinks(client: MathpixClient, tmp_path) -> None: + pdf_path = tmp_path / 'document.pdf' + pdf_path.write_bytes(b'%PDF-1.4') + with patch('mpxpy.mathpix_client.post') as mock_post: + mock_post.return_value.json.return_value = {'pdf_id': 'pdf-1'} + client.pdf_new(file_path=str(pdf_path), convert_to_xlsx=True, include_hyperlinks=True) + options = json.loads(mock_post.call_args.kwargs['data']['options_json']) + assert options['include_hyperlinks'] is True + assert options['conversion_formats']['xlsx'] is True + + +def test_conversion_new_sends_convert_to_xlsx(client: MathpixClient) -> None: + with patch('mpxpy.mathpix_client.post') as mock_post: + mock_post.return_value.json.return_value = {'conversion_id': 'conversion-1'} + client.conversion_new(mmd='# Document', convert_to_xlsx=True) + assert mock_post.call_args.kwargs['json']['formats'] == {'xlsx': True} + + +def test_pdf_new_sends_mmd_overlay_pdf_and_options(client: MathpixClient, tmp_path) -> None: + pdf_path = tmp_path / 'document.pdf' + pdf_path.write_bytes(b'%PDF-1.4') + with patch('mpxpy.mathpix_client.post') as mock_post: + mock_post.return_value.json.return_value = {'pdf_id': 'pdf-1'} + client.pdf_new( + file_path=str(pdf_path), + convert_to_mmd_overlay_pdf=True, + conversion_options={'mmd.overlay.pdf': {'pdf_ua': 2, 'validation_report': True}}, + ) + options = json.loads(mock_post.call_args.kwargs['data']['options_json']) + assert options['conversion_formats']['mmd.overlay.pdf'] is True + assert options['conversion_options'] == {'mmd.overlay.pdf': {'pdf_ua': 2, 'validation_report': True}}