Skip to content

Fix bug where astropy writes XML files that it cannot read - #20469

Open
jdavies-st wants to merge 6 commits into
astropy:mainfrom
jdavies-st:bugfix-votable-xml-roundtrip
Open

jdavies-st wants to merge 6 commits into
astropy:mainfrom
jdavies-st:bugfix-votable-xml-roundtrip

Conversation

@jdavies-st

@jdavies-st jdavies-st commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Description

XML forbids certain characters from appearing anywhere in a document. Astropy enforces this when reading, because expat does, but not when writing. So astropy would happily write files it could not read back, and in some cases quietly alter the data instead.

This PR checks characters on the way out. A field name, attribute, description or table cell containing one now raises ValueError instead of producing a broken file.

What users will see

  • Writing a value containing a character XML does not allow now raises ValueError, naming the character and where it was: the attribute, the element, or the row and column. Previously most were written verbatim and the file could not be parsed afterwards.
  • Two cases that were silent are now reported. A null was deleted by the escaper wherever it appeared, and a vertical tab or form feed in a description became a space. Both changed the data with no error.
  • Only the TABLEDATA serialization is affected, because it stores values as XML text. BINARY and BINARY2 base64 encode them, so these characters are representable there and are still written.
  • The escaping helpers are documented as taking text. Bytes are still escaped and returned, but not checked, since their encoding is not known there.

Implementation note

The check piggybacks on the escaper/sanitizer that already walks every value on its way out, rather than adding a second pass, so it costs nothing measurable.

This follows #20439, which fixed a similar class of problem for XML tokens. It does not address check_token() and its callers silently discarding what the check returns; that should be a separate PR. And that likely requires care, as readers need to not raise, but the writer should probably warn.

Fixes #20457

AI Disclosure

Claude Opus 5.5 was used to investigate, iterate and write fixes in this PR. I have carefully reviewed, modified where needed and tested the changes.

  • I certify that I am human and that I take full responsibility for this pull request including all interactions with reviewers.

Merge method

  • By checking this box, the PR author has requested that maintainers do NOT use the "Squash and Merge" button. Maintainers should respect this when possible; however, the final decision is at the discretion of the maintainer that merges the PR.

- Check text against the characters XML allows before escaping it, so
  writing a field name, attribute, description or table cell containing
  one of them raises ValueError. Before, most were written out verbatim,
  producing a file that astropy could not read back. A null was deleted
  by the escaper wherever it appeared, and in description text a vertical
  tab or form feed became a space, so those altered the data with no
  error at all.
- Reject bad XML characters in XMLWriter._flush() before textwrap.fill()
  runs and whenever xml_cleaning_method() has replaced the escaper, since
  none of the wrapped text or the alternate xml cleaning methods
  (bleach.clean, or the "none" option) would notice.
- Name the attribute, element, or row and column in the error. The C
  table writer cannot say which cell it was on, so _write_tabledata()
  locates it after the failure, which costs nothing unless a write has
  already failed, and reports it identically to the Python table writer.
- Fix a null byte in bytes input being deleted rather than written out.
  The list of characters the escaper replaces ends in a marker entry that
  uses a null, and a real null in the input matched that marker and was
  replaced with nothing. The loops now check for the end of the list
  before comparing.
- Leave bytes input escaped but unchecked, since its text encoding is not
  known, and say so in the docstrings. These functions are for text.
- Only the TABLEDATA serialization format is affected, since it stores
  values as XML text. BINARY and BINARY2 store them base64 encoded, so
  these characters are representable there and are still written.
- Add unit tests to check all these cases.
@github-actions

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Astropy! 🌌 This checklist is meant to remind the package maintainers who will review this pull request of some common things to look for.

  • Do the proposed changes actually accomplish desired goals?
  • Do the proposed changes follow the Astropy coding guidelines?
  • Are tests added/updated as required? If so, do they follow the Astropy testing guidelines?
  • Are docs added/updated as required? If so, do they follow the Astropy documentation guidelines?
  • Is rebase and/or squash necessary? If so, please provide the author with appropriate instructions. Also see instructions for rebase and squash.
  • Did the CI pass? If no, are the failures related? If you need to run daily and weekly cron jobs as part of the PR, please apply the "Extra CI" label. Codestyle issues can be fixed by the bot.
  • Is a change log needed? If yes, did the change log check pass? If no, add the "no-changelog-entry-needed" label. If this is a manual backport, use the "skip-changelog-checks" label unless special changelog handling is necessary.
  • Is this a big PR that makes a "What's new?" entry worthwhile and if so, is (1) a "what's new" entry included in this PR and (2) the "whatsnew-needed" label applied?
  • At the time of adding the milestone, if the milestone set requires a backport to release branch(es), apply the appropriate "backport-X.Y.x" label(s) before merge.

- PyUnicode_GET_LENGTH, PyUnicode_KIND, PyUnicode_DATA and
  PyUnicode_READ are not part of the limited API.
- Use PyUnicode_GetLength and PyUnicode_ReadChar instead for the
  saem behavior.
@pllim pllim added this to the v8.1.0 milestone Sep 23, 2026
@pllim pllim added Bug Extra CI Run cron CI as part of PR labels Sep 23, 2026
``ValueError`` if a field name, attribute, description or table cell contains
a character that XML does not allow, instead of writing a file that cannot be
read back. Nulls, which were previously dropped without warning, are also
refused. The ``BINARY`` and ``BINARY2`` formats are unaffected, since they

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor nit, but I think BINARY2 and BINARY2 are unaffected in terms of the cell data, but the field names, units, UCDs and descriptions of VOTables with binary serialized data will still go through XMLWriter so a bad character would still raise an exception, so perhaps worth slightly rewording that sentence to clarify that?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Doh! Yes, exactly. And I ran a small test to confirm. Only the cell data is exempt in BINARY and BINARY2. I have updated the changelog to

Writing a VOTable now raises a ``ValueError`` if a field name, 
description or table cell contains a character that XML does not allow,
instead of writing a file that cannot be read back. Nulls, which were
previously dropped without warning, are also refused. Cell data is exempt in
the ``BINARY`` and ``BINARY2`` serialization formats, which store values
base64 encoded rather than as XML text; the names, units, UCDs and
descriptions of such a table are still written as XML and are still checked.

@jdavies-st
jdavies-st force-pushed the bugfix-votable-xml-roundtrip branch from d1a0abe to e2ab3e8 Compare September 24, 2026 21:20

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Extra CI Run cron CI as part of PR io.votable utils

Projects

None yet

Development

Successfully merging this pull request may close these issues.

astropy.io.votable can write XML files that it cannot read

3 participants