FoXy v0.8.0 is out

FoXy

Fortran XML for poor people

FoXy is a pure Fortran library to read, query, edit, and write XML, with no C underneath: use foxy and your code talks XML (hopefully). It started as a small parser for another poor people project (VTKFortran); the last four releases (0.5 to 0.8) rebuilt it (with the help of an AI assistant) into what scientific code needs from XML (at least in my mind, even though I prefer other kinds of I/O formats generally): case files read as numbers, results metadata written as you compute, and files of any size, including over 2 GiB.

Five types, one for each way of working with a document:

Type For
xml_file a whole document in memory (a DOM): parse, query, edit, save
xml_index a file too large to hold: tags and attributes, contents read on demand
xml_handler a document of any size read once: your callbacks, nothing stored
xml_writer a document of any size, written element by element to a file, a unit or memory
xml_tag a single element: build it, parse it, write it

foxy_tour_cast

Your input file, read as numbers

The common case: a solver reads its settings from XML. No string juggling, no read(..., *):

use foxy
type(xml_file) :: doc
real(R8P)      :: dt
integer(I4P)   :: steps

call doc%parse(filename='case.xml')
call doc%get_content(id=doc%find(path='case/time/dt'), value=dt)
call doc%get_content(id=doc%find(path='case/time/steps'), value=steps)
  • Typed values. Contents and attributes as any integer or real kind, logicals, and lists (values= into an allocatable array), with iostat/iomsg. A value that is not a number gives XML_BAD_VALUE and leaves your variable unchanged; a missing tag or attribute gives XML_NOT_FOUND. 1e3 is not accepted as an integer.
  • Tolerant, but it tells you. A malformed file is still built as far as it goes, and parse returns XML_MALFORMED with the first problem and its line: tag structure, names, duplicate attributes, a second root, text outside the root, malformed references, ill-formed UTF-8.

New: path expressions and regular expressions

ids = doc%select('case/boundary[@type="wall"]')                 ! attribute value
ids = doc%select('//*[@unit]')                                  ! any name, at any depth
ids = doc%select('/case/boundary[2]')                           ! the second boundary child
ids = doc%select('//boundary[@side~="^(east|west)$"]/velocity') ! regular expression
  • An XPath subset that covers the usual lookups: /, //, *, [@a], [@a="v"], [@a~="re"], [n] among siblings. A malformed expression returns no IDs and the column of the problem in iomsg. It is a subset: no axes, functions or arithmetic.
  • Regular expressions on names, contents and attribute values with matching(re, ...), using the StringiFor engine (Python re syntax, linear time, no backtracking).
  • Namespaces in find/find_all: by prefix, by {uri}local, or with your own prefixes mapped to URIs.

New: write numbers, not strings

type(xml_writer) :: w

call w%open()
call w%start('cell')
call w%attribute('dt', 0.1_R8P)                        ! the shortest text that reads back: 0.1
call w%attribute('extent', [0_I4P, 63_I4P, 0_I4P, 31_I4P])
call w%start('volume')
call w%text(1._R8P/3._R8P)
call w%end('volume')
call w%start('center')
call w%text([0.5_R8P, 0.25_R8P, 0._R8P], fmt='(F5.3)')
call w%end('center')
call w%close()
<cell dt="0.1" extent="0 63 0 31">
  <volume>0.3333333333333333</volume>
  <center>0.500 0.250 0.000</center>
</cell>
  • Reals as their shortest round-trip text (0.1, not 1.000000000000000E-001), or your own fmt=. A value too wide for the field is an error, not a row of asterisks.
  • Only well-formed XML. The writer escapes & < > " ', checks names, duplicate attributes, one root element, and PI targets. Errors are sticky and reported once, by close or iostat.
  • The DOM does the same: set_content/set_attribute with value=/values=; add_tag(parent=|before=|after=), set_name, delete_tag, then save.

New: the streaming parser

type, extends(xml_handler) :: pressure_sum
   ...
   contains
      procedure :: start_element => on_start
      procedure :: characters    => on_text
      procedure :: end_element   => on_end
endtype pressure_sum

call summer%parse(filename='results.xml', iostat=iostat)

Extend xml_handler, override the callbacks you need (start and end tags, text, CDATA, comments, PIs, DOCTYPE, problems with their line), and the file is read in chunks with nothing stored; stop_parsing ends it early. Your callbacks are plain type-bound procedures: I/O and side effects are allowed.

For random access to files too large for a DOM, there is xml_index: it scans the tags and attributes only, and reads content from the file when you ask for it (stop_at= stops the scan early).

New: encodings

UTF-8, UTF-16, UTF-32 (byte order mark or not), ISO-8859-1, Windows-1252, and US-ASCII sources are detected (XML 1.0 Appendix F, then the declaration) and converted to UTF-8 piece by piece. save writes UTF-8 and declares it. An encoding FoXy does not read is reported, not guessed.

A modern XML helper for your code

What I use it for, and what it is designed for:

  • Input files. Read a case file as typed values with error statuses, with defaults when a tag is missing (XML_NOT_FOUND), and report a broken file with its line.
  • Results metadata. VTK XML and XDMF headers written with xml_writer as the run goes, numbers formatted for you. VTKFortran writes its XML formats through FoXy.
  • Catalogues and large datasets. xml_index or xml_handler on files of hundreds of MB or over 2 GiB, without loading them.
  • Round-tripping edits. Parse, change a few values by tag ID, save: the prolog, comments, CDATA, and mixed content are kept, so a file edited by your code still diffs cleanly.

Performance: parsing is linear in the document size. A generated QuakeML-like catalogue of 100,000 tags (3 MB) is parsed into the DOM in about 0.11 s and written back by stringify in 0.03 s (gfortran 16, -O2, a desktop CPU; fobis build --mode benchmark-gnu reproduces it).

Known limits: entities declared in a DTD are not expanded, and there is no validation against a DTD or schema.

How it is checked

43 test programs, run with gfortran, nvfortran and ifx (debug builds with runtime checks included). Chunked parsing is checked against whole-string parsing down to 1-character chunks, the streaming events against the DOM on every test file, and the index against the DOM tag by tag. Every sample in the documentation is a program compiled and run to produce the output shown, and CI fails if the output changes.

The documentation has a tutorial that builds the input and output of a small solver in nine chapters, a cookbook, and an Upgrading page listing the behaviour changes release by release.


FoXy builds with FoBiS, fpm, or CMake, is tested with GNU gfortran, NVIDIA nvfortran, and Intel ifx, and is multi-licensed (GPL v3, BSD 2-Clause, BSD 3-Clause, MIT, select the best fit for you).

Feedback, issues, and contributions are welcome.

Stefano

1 Like