Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions geos-xml-tools/src/geos/xml_tools/command_line_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ def build_xml_formatter_input_parser() -> argparse.ArgumentParser:
parser.add_argument( '-a', '--alphebitize', type=int, help='Alphebetize attributes', default=0 )
parser.add_argument( '-c', '--close', type=int, help='Close tag style', default=0 )
parser.add_argument( '-n', '--namespace', type=int, help='Include namespace', default=0 )
parser.add_argument( '-l',
'--line-length',
type=int,
help='Write leaf blocks on one line when they fit in this many columns (0 disables)',
default=100 )
return parser


Expand Down
83 changes: 82 additions & 1 deletion geos-xml-tools/src/geos/xml_tools/tests/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
import unittest
import re
import os
import tempfile
import filecmp
from geos.xml_tools import regex_tools, unit_manager, xml_processor
from geos.xml_tools import regex_tools, unit_manager, xml_processor, xml_formatter
from geos.xml_tools.tests import generate_test_xml
import argparse
from parameterized import parameterized
Expand Down Expand Up @@ -172,6 +173,82 @@ def test_xml_processor( self: Self, input_file: str, target_file: str, expect_fa
self.assertTrue( expect_fail )


class TestXMLFormatter( unittest.TestCase ):

def test_short_leaf_blocks_are_one_line( self: Self ) -> None:
"""Leaf blocks that fit in 100 columns are written on one line."""
with tempfile.TemporaryDirectory() as tmp:
fname = os.path.join( tmp, 'compact_leaves.xml' )
with open( fname, 'w' ) as f:
f.write( """<Problem>
<Geometry>
<Box
name="source"
xMin="{-0.01, -0.01, -0.01}"
xMax="{1.01, 1.01, 1.01}"/>
</Geometry>
<FieldSpecifications>
<FieldSpecification
name="permx"
setNames="{all}"
fieldName="permeability"
scale="2.0e-16"/>
<SourceFlux
name="sourceTerm"
setNames="{source}"
scale="-0.00001"/>
</FieldSpecifications>
</Problem>
""" )
xml_formatter.format_file( fname )
with open( fname, 'r' ) as f:
text = f.read()

box_line = ' <Box name="source" xMin="{ -0.01, -0.01, -0.01 }" xMax="{ 1.01, 1.01, 1.01 }"/>'
fs_line = ' <FieldSpecification name="permx" setNames="{ all }" fieldName="permeability" scale="2.0e-16"/>'
flux_line = ' <SourceFlux name="sourceTerm" setNames="{ source }" scale="-0.00001"/>'
self.assertLessEqual( len( box_line ), xml_formatter.DEFAULT_MAX_LINE_LENGTH )
self.assertLessEqual( len( fs_line ), xml_formatter.DEFAULT_MAX_LINE_LENGTH )
self.assertLessEqual( len( flux_line ), xml_formatter.DEFAULT_MAX_LINE_LENGTH )
self.assertIn( box_line, text )
self.assertIn( fs_line, text )
self.assertIn( flux_line, text )
self.assertRegex( text, r'<Geometry>\s*\n\s*<Box' )
self.assertRegex( text, r'<FieldSpecifications>\s*\n\s*<FieldSpecification' )

def test_long_leaf_blocks_stay_wrapped( self: Self ) -> None:
"""Leaf blocks that exceed 100 columns keep one attribute per line."""
with tempfile.TemporaryDirectory() as tmp:
fname = os.path.join( tmp, 'long_leaf.xml' )
with open( fname, 'w' ) as f:
f.write( """<Problem>
<FieldSpecification name="permx" component="0" initialCondition="1" setNames="{all}" objectPath="ElementRegions/Region1/block1" fieldName="permeability" scale="2.0e-16"/>
</Problem>
""" )
xml_formatter.format_file( fname )
with open( fname, 'r' ) as f:
text = f.read()

long_line = ( ' <FieldSpecification name="permx" component="0" initialCondition="1" '
'setNames="{ all }" objectPath="ElementRegions/Region1/block1" '
'fieldName="permeability" scale="2.0e-16"/>' )
self.assertGreater( len( long_line ), xml_formatter.DEFAULT_MAX_LINE_LENGTH )
self.assertNotIn( '<FieldSpecification name="permx"', text )
self.assertIn( '\n name="permx"', text )

def test_line_length_zero_disables_compact_leaves( self: Self ) -> None:
"""max_line_length=0 keeps the previous one-attribute-per-line layout."""
with tempfile.TemporaryDirectory() as tmp:
fname = os.path.join( tmp, 'compact_leaves_disabled.xml' )
with open( fname, 'w' ) as f:
f.write( '<Problem>\n <Box name="source" xMin="{0,0,0}" xMax="{1,1,1}"/>\n</Problem>\n' )
xml_formatter.format_file( fname, max_line_length=0 )
with open( fname, 'r' ) as f:
text = f.read()
self.assertIn( '\n name="source"', text )
self.assertNotIn( '<Box name="source"', text )


def run_unit_tests( test_dir: str, verbose: int ) -> None:
"""Main entry point for the unit tests.

Expand Down Expand Up @@ -204,6 +281,10 @@ def run_unit_tests( test_dir: str, verbose: int ) -> None:
suite = unittest.TestLoader().loadTestsFromTestCase( TestXMLProcessor )
unittest.TextTestRunner( verbosity=verbose ).run( suite )

# xml formatter tests
suite = unittest.TestLoader().loadTestsFromTestCase( TestXMLFormatter )
unittest.TextTestRunner( verbosity=verbose ).run( suite )

os.chdir( pwd )


Expand Down
192 changes: 129 additions & 63 deletions geos-xml-tools/src/geos/xml_tools/xml_formatter.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import os
from lxml import etree as ElementTree # type: ignore[import]
import re
from typing import List, Any, TextIO
from typing import Dict, List, Any, TextIO
from geos.xml_tools import command_line_parsers

DEFAULT_MAX_LINE_LENGTH = 100


def format_attribute( attribute_indent: str, ka: str, attribute_value: str ) -> str:
"""Format xml attribute strings.
Expand Down Expand Up @@ -39,6 +41,82 @@ def format_attribute( attribute_indent: str, ka: str, attribute_value: str ) ->
return attribute_value


def collect_attributes( node: ElementTree.Element, level: int, attribute_indent: str, sort_attributes: bool,
include_namespace: bool ) -> Dict[ str, str ]:
"""Collect and format attributes for an xml element.

Args:
node (lxml.etree.Element): the current xml element
level (int): the xml depth
attribute_indent (str): Attribute indent string
sort_attributes (bool): option to sort attributes alphabetically
include_namespace (bool): option to include the xml namespace in the output

Returns:
dict: Ordered attribute name/value pairs
"""
attribute_dict: Dict[ str, str ] = {}
if ( ( level == 0 ) & include_namespace ):
# Handle the optional namespace information at the root level
# Note: preferably, this would point to a schema we host online
attribute_dict[ 'xmlns:xsi' ] = 'http://www.w3.org/2001/XMLSchema-instance'
attribute_dict[ 'xsi:noNamespaceSchemaLocation' ] = '/usr/gapps/GEOS/schema/schema.xsd'
elif ( level > 0 ):
attribute_dict = dict( node.attrib )

Comment thread
victorapm marked this conversation as resolved.
akeys = list( attribute_dict.keys() )
if sort_attributes:
akeys = sorted( akeys )

formatted: Dict[ str, str ] = {}
for ka in akeys:
# Avoid formatting mathpresso expressions
if not ( node.tag in [ "SymbolicFunction", "CompositeFunction" ] and ka == "expression" ):
formatted[ ka ] = format_attribute( attribute_indent, ka, attribute_dict[ ka ] )
else:
formatted[ ka ] = attribute_dict[ ka ]
return formatted


def compact_leaf_line( indent: str, level: int, tag: str, attribute_dict: Dict[ str, str ] ) -> str:
"""Build the one-line representation of a leaf element.

Args:
indent (str): the xml indent style
level (int): the xml depth
tag (str): element tag
attribute_dict (dict): formatted attribute name/value pairs

Returns:
str: Candidate single-line element, without a leading newline
"""
line = '%s<%s' % ( indent * level, tag )
for k, v in attribute_dict.items():
line += ' %s=\"%s\"' % ( k, v )
return line + '/>'


def should_write_compact_leaf( node: ElementTree.Element, attribute_dict: Dict[ str, str ], indent: str, level: int,
max_line_length: int ) -> bool:
"""Return True if a leaf element fits on one line.

Args:
node (lxml.etree.Element): the current xml element
attribute_dict (dict): formatted attribute name/value pairs
indent (str): the xml indent style
level (int): the xml depth
max_line_length (int): maximum columns for a compact leaf; 0 disables

Returns:
bool: True if the element has no children and the compact line is short enough
"""
if max_line_length <= 0 or len( node ):
return False
if any( '\n' in value for value in attribute_dict.values() ):
return False
return len( compact_leaf_line( indent, level, node.tag, attribute_dict ) ) <= max_line_length


def format_xml_level( output: TextIO,
node: ElementTree.Element,
level: int,
Expand All @@ -47,7 +125,8 @@ def format_xml_level( output: TextIO,
modify_attribute_indent: bool = False,
sort_attributes: bool = False,
close_tag_newline: bool = False,
include_namespace: bool = False ) -> None:
include_namespace: bool = False,
max_line_length: int = DEFAULT_MAX_LINE_LENGTH ) -> None:
"""Iteratively format the xml file.

Args:
Expand All @@ -60,71 +139,53 @@ def format_xml_level( output: TextIO,
sort_attributes (bool): option to sort attributes alphabetically
close_tag_newline (bool): option to place close tag on a separate line
include_namespace (bool): option to include the xml namespace in the output
max_line_length (int): write leaf blocks on one line when they fit in this many columns
"""
# Handle comments
if node.tag is ElementTree.Comment:
output.write( '\n%s<!--%s-->' % ( indent * level, node.text ) )
return

opening_line = '\n%s<%s' % ( indent * level, node.tag )
attribute_indent = '%s' % ( indent * ( level + 1 ) )
if modify_attribute_indent:
attribute_indent = ' ' * ( len( opening_line ) )

attribute_dict = collect_attributes( node, level, attribute_indent, sort_attributes, include_namespace )
akeys = list( attribute_dict.keys() )

if should_write_compact_leaf( node, attribute_dict, indent, level, max_line_length ):
output.write( '\n' + compact_leaf_line( indent, level, node.tag, attribute_dict ) )
return
Comment thread
victorapm marked this conversation as resolved.
Outdated

output.write( opening_line )

for ii in range( 0, len( akeys ) ):
k = akeys[ ii ]
if ( ( ii == 0 ) & modify_attribute_indent ):
output.write( ' %s=\"%s\"' % ( k, attribute_dict[ k ] ) )
else:
output.write( '\n%s%s=\"%s\"' % ( attribute_indent, k, attribute_dict[ k ] ) )

# Write children
if len( node ):
output.write( '>' )
Nc = len( node )
for ii, child in zip( range( Nc ), node, strict=False ):
format_xml_level( output, child, level + 1, indent, block_separation_max_depth, modify_attribute_indent,
sort_attributes, close_tag_newline, include_namespace, max_line_length )

# Add space between blocks
if ( ( level < block_separation_max_depth ) & ( ii < Nc - 1 ) & ( child.tag is not ElementTree.Comment ) ):
output.write( '\n' )

# Write the end tag
output.write( '\n%s</%s>' % ( indent * level, node.tag ) )
else:
# Write opening line
opening_line = '\n%s<%s' % ( indent * level, node.tag )
output.write( opening_line )

# Write attributes
if ( len( node.attrib ) > 0 ):
# Choose indentation
attribute_indent = '%s' % ( indent * ( level + 1 ) )
if modify_attribute_indent:
attribute_indent = ' ' * ( len( opening_line ) )

# Get a copy of the attributes
attribute_dict = {}
if ( ( level == 0 ) & include_namespace ):
# Handle the optional namespace information at the root level
# Note: preferably, this would point to a schema we host online
attribute_dict[ 'xmlns:xsi' ] = 'http://www.w3.org/2001/XMLSchema-instance'
attribute_dict[ 'xsi:noNamespaceSchemaLocation' ] = '/usr/gapps/GEOS/schema/schema.xsd'
elif ( level > 0 ):
attribute_dict = node.attrib

# Sort attribute names
akeys = list( attribute_dict.keys() )
if sort_attributes:
akeys = sorted( akeys )

# Format attributes
for ka in akeys:
# Avoid formatting mathpresso expressions
if not ( node.tag in [ "SymbolicFunction", "CompositeFunction" ] and ka == "expression" ):
attribute_dict[ ka ] = format_attribute( attribute_indent, ka, attribute_dict[ ka ] )

for ii in range( 0, len( akeys ) ):
k = akeys[ ii ]
if ( ( ii == 0 ) & modify_attribute_indent ):
output.write( ' %s=\"%s\"' % ( k, attribute_dict[ k ] ) )
else:
output.write( '\n%s%s=\"%s\"' % ( attribute_indent, k, attribute_dict[ k ] ) )

# Write children
if len( node ):
output.write( '>' )
Nc = len( node )
for ii, child in zip( range( Nc ), node, strict=False ):
format_xml_level( output, child, level + 1, indent, block_separation_max_depth, modify_attribute_indent,
sort_attributes, close_tag_newline, include_namespace )

# Add space between blocks
if ( ( level < block_separation_max_depth ) & ( ii < Nc - 1 ) &
( child.tag is not ElementTree.Comment ) ):
output.write( '\n' )

# Write the end tag
output.write( '\n%s</%s>' % ( indent * level, node.tag ) )
if close_tag_newline:
output.write( '\n%s/>' % ( indent * level ) )
else:
if close_tag_newline:
output.write( '\n%s/>' % ( indent * level ) )
else:
output.write( '/>' )
output.write( '/>' )


def format_file( input_fname: str,
Expand All @@ -133,7 +194,8 @@ def format_file( input_fname: str,
block_separation_max_depth: int = 2,
alphebitize_attributes: bool = False,
close_style: bool = False,
namespace: bool = False ) -> None:
namespace: bool = False,
max_line_length: int = DEFAULT_MAX_LINE_LENGTH ) -> None:
"""Script to format xml files.

Args:
Expand All @@ -144,6 +206,7 @@ def format_file( input_fname: str,
alphebitize_attributes (bool): Alphebitize attributes
close_style (bool): Style of close tag (0=same line, 1=new line)
namespace (bool): Insert this namespace in the xml description
max_line_length (int): Write leaf blocks on one line when they fit in this many columns
"""
fname = os.path.expanduser( input_fname )
try:
Expand All @@ -166,7 +229,8 @@ def format_file( input_fname: str,
modify_attribute_indent=indent_style,
sort_attributes=alphebitize_attributes,
close_tag_newline=close_style,
include_namespace=namespace )
include_namespace=namespace,
max_line_length=max_line_length )

for comment in epilog_comments:
f.write( '\n<!--%s-->' % ( comment ) )
Expand All @@ -189,6 +253,7 @@ def main() -> None:
-a/--alphebitize (int): Alphebitize attributes
-c/--close (int): Close tag style
-n/--namespace (int): Include namespace
-l/--line-length (int): Max columns for a one-line leaf block (0 disables)
"""
parser = command_line_parsers.build_xml_formatter_input_parser()
args = parser.parse_args()
Expand All @@ -198,7 +263,8 @@ def main() -> None:
block_separation_max_depth=args.depth,
alphebitize_attributes=args.alphebitize,
close_style=args.close,
namespace=args.namespace )
namespace=args.namespace,
max_line_length=args.line_length )


if __name__ == "__main__":
Expand Down
Loading