645 lines
21 KiB
Python
645 lines
21 KiB
Python
# Copyright (c) 2020 Nordic Semiconductor ASA
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""
|
|
Like gen_kconfig_rest.py, but for generating an index of existing
|
|
devicetree bindings.
|
|
"""
|
|
|
|
import argparse
|
|
from collections import defaultdict
|
|
import glob
|
|
import io
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import pprint
|
|
import re
|
|
import sys
|
|
import textwrap
|
|
|
|
import edtlib
|
|
|
|
import gen_helpers
|
|
|
|
# A line of '-' characters in vendor-prefixes.txt that separates
|
|
# the data from comments about it.
|
|
VENDOR_PREFIXES_SEPARATOR = '-' * 50
|
|
|
|
ZEPHYR_BASE = Path(__file__).parents[2]
|
|
|
|
GENERIC_OR_VENDOR_INDEPENDENT = 'Generic or vendor-independent'
|
|
UNKNOWN_VENDOR = 'Unknown vendor'
|
|
|
|
ZEPHYR_BASE = Path(__file__).parents[2]
|
|
|
|
# Base properties that have documentation in 'dt-important-props'.
|
|
DETAILS_IN_IMPORTANT_PROPS = set('compatible label reg status interrupts'.split())
|
|
|
|
logger = logging.getLogger('gen_devicetree_rest')
|
|
|
|
class VndLookup:
|
|
"""
|
|
A convenience class for looking up information based on a
|
|
devicetree compatible's vendor prefix 'vnd'.
|
|
"""
|
|
|
|
def __init__(self, vendor_prefixes, bindings):
|
|
self.vnd2vendor = self.load_vnd2vendor(vendor_prefixes)
|
|
self.vnd2bindings = self.init_vnd2bindings(bindings)
|
|
self.vnd2ref_target = self.init_vnd2ref_target()
|
|
|
|
def vendor(self, vnd):
|
|
return self.vnd2vendor.get(vnd, UNKNOWN_VENDOR)
|
|
|
|
def bindings(self, vnd, default=None):
|
|
return self.vnd2bindings.get(vnd, default)
|
|
|
|
def target(self, vnd):
|
|
return self.vnd2ref_target.get(
|
|
vnd, self.vnd2ref_target[(UNKNOWN_VENDOR,)])
|
|
|
|
@staticmethod
|
|
def load_vnd2vendor(vendor_prefixes):
|
|
# Load the vendor-prefixes.txt file. Return a dict mapping 'vnd'
|
|
# vendor prefixes as they are found in compatible properties to
|
|
# each vendor's full name.
|
|
#
|
|
# For example, this line:
|
|
#
|
|
# vnd A stand-in for a real vendor
|
|
#
|
|
# Gets split into a key 'vnd' and a value 'A stand-in for a real
|
|
# vendor' in the return value.
|
|
#
|
|
# The 'None' key maps to GENERIC_OR_VENDOR_INDEPENDENT.
|
|
|
|
vnd2vendor = {
|
|
None: GENERIC_OR_VENDOR_INDEPENDENT,
|
|
}
|
|
found_separator = False # have we found the '-----' separator?
|
|
with open(vendor_prefixes, 'r') as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line and found_separator:
|
|
# Every line after the separator should be in this form:
|
|
#
|
|
# <vnd><TAB><vendor>
|
|
vnd_vendor = line.split('\t', 1)
|
|
assert len(vnd_vendor) == 2, line
|
|
vnd2vendor[vnd_vendor[0]] = vnd_vendor[1]
|
|
elif line.startswith(VENDOR_PREFIXES_SEPARATOR):
|
|
found_separator = True
|
|
|
|
logger.info('found %d vendor prefixes in %s', len(vnd2vendor) - 1,
|
|
vendor_prefixes)
|
|
if logger.isEnabledFor(logging.DEBUG):
|
|
logger.debug('vnd2vendor=%s', pprint.pformat(vnd2vendor))
|
|
|
|
return vnd2vendor
|
|
|
|
def init_vnd2bindings(self, bindings):
|
|
# Take a 'vnd2vendor' map and a list of bindings and return a dict
|
|
# mapping 'vnd' vendor prefixes prefixes to lists of bindings. The
|
|
# bindings in each list are sorted by compatible. The keys in the
|
|
# return value are sorted by vendor name.
|
|
#
|
|
# Special cases:
|
|
#
|
|
# - The 'None' key maps to bindings with no vendor prefix
|
|
# in their compatibles, like 'gpio-keys'. This is the first key.
|
|
# - The (UNKNOWN_VENDOR,) key maps to bindings whose compatible
|
|
# has a vendor prefix that exists, but is not known,
|
|
# like 'somethingrandom,device'. This is the last key.
|
|
|
|
# Get an unsorted dict mapping vendor prefixes to lists of bindings.
|
|
unsorted = defaultdict(list)
|
|
generic_bindings = []
|
|
unknown_vendor_bindings = []
|
|
for binding in bindings:
|
|
vnd = compatible_vnd(binding.compatible)
|
|
if vnd is None:
|
|
generic_bindings.append(binding)
|
|
elif vnd in self.vnd2vendor:
|
|
unsorted[vnd].append(binding)
|
|
else:
|
|
unknown_vendor_bindings.append(binding)
|
|
|
|
# Key functions for sorting.
|
|
def vnd_key(vnd):
|
|
return self.vnd2vendor[vnd].casefold()
|
|
|
|
def binding_key(binding):
|
|
return binding.compatible
|
|
|
|
# Sort the bindings for each vendor by compatible.
|
|
# Plain dicts are sorted in CPython 3.6+, which is what we
|
|
# support, so the return dict's keys are in the same
|
|
# order as vnd2vendor.
|
|
#
|
|
# The unknown-vendor bindings being inserted as a 1-tuple key is a
|
|
# hack for convenience that ensures they won't collide with a
|
|
# known vendor. The code that consumes the dict below handles
|
|
# this.
|
|
vnd2bindings = {
|
|
None: sorted(generic_bindings, key=binding_key)
|
|
}
|
|
for vnd in sorted(unsorted, key=vnd_key):
|
|
vnd2bindings[vnd] = sorted(unsorted[vnd], key=binding_key)
|
|
vnd2bindings[(UNKNOWN_VENDOR,)] = sorted(unknown_vendor_bindings,
|
|
key=binding_key)
|
|
|
|
if logger.isEnabledFor(logging.DEBUG):
|
|
logger.debug('vnd2bindings: %s', pprint.pformat(vnd2bindings))
|
|
|
|
return vnd2bindings
|
|
|
|
def init_vnd2ref_target(self):
|
|
# The return value, vnd2ref_target, is a dict mapping vendor
|
|
# prefixes to ref targets for their relevant sections in this
|
|
# file, with these special cases:
|
|
#
|
|
# - The None key maps to the ref target for bindings with no
|
|
# vendor prefix in their compatibles, like 'gpio-keys'
|
|
# - The (UNKNOWN_VENDOR,) key maps to the ref target for bindings
|
|
# whose compatible has a vendor prefix that is not recognized.
|
|
vnd2ref_target = {}
|
|
|
|
for vnd in self.vnd2bindings:
|
|
if vnd is None:
|
|
vnd2ref_target[vnd] = 'dt_no_vendor'
|
|
elif isinstance(vnd, str):
|
|
vnd2ref_target[vnd] = f'dt_vendor_{vnd}'
|
|
else:
|
|
assert vnd == (UNKNOWN_VENDOR,), vnd
|
|
vnd2ref_target[vnd] = 'dt_unknown_vendor'
|
|
|
|
return vnd2ref_target
|
|
|
|
def main():
|
|
args = parse_args()
|
|
setup_logging(args.verbose)
|
|
bindings = load_bindings(args.dts_roots)
|
|
base_binding = load_base_binding()
|
|
vnd_lookup = VndLookup(args.vendor_prefixes, bindings)
|
|
dump_content(bindings, base_binding, vnd_lookup, args.out_dir)
|
|
|
|
def parse_args():
|
|
# Parse command line arguments from sys.argv.
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('-v', '--verbose', default=0, action='count',
|
|
help='increase verbosity; may be given multiple times')
|
|
parser.add_argument('--vendor-prefixes', required=True,
|
|
help='vendor-prefixes.txt file path')
|
|
parser.add_argument('--dts-root', dest='dts_roots', action='append',
|
|
help='''additional DTS root directory as it would
|
|
be set in DTS_ROOTS''')
|
|
parser.add_argument('out_dir', help='output files are generated here')
|
|
|
|
return parser.parse_args()
|
|
|
|
def setup_logging(verbose):
|
|
if verbose >= 2:
|
|
log_level = logging.DEBUG
|
|
elif verbose == 1:
|
|
log_level = logging.INFO
|
|
else:
|
|
log_level = logging.ERROR
|
|
logging.basicConfig(format='%(filename)s:%(levelname)s: %(message)s',
|
|
level=log_level)
|
|
|
|
def load_bindings(dts_roots):
|
|
# Get a list of edtlib.Binding objects from searching 'dts_roots'.
|
|
|
|
if not dts_roots:
|
|
sys.exit('no DTS roots; use --dts-root to specify at least one')
|
|
|
|
binding_files = []
|
|
for dts_root in dts_roots:
|
|
binding_files.extend(glob.glob(f'{dts_root}/dts/bindings/**/*.yaml'))
|
|
|
|
bindings = edtlib.bindings_from_paths(binding_files, ignore_errors=True)
|
|
|
|
num_total = len(bindings)
|
|
|
|
# Remove bindings from the 'vnd' vendor, which is not a real vendor,
|
|
# but rather a stand-in we use for examples and tests when a real
|
|
# vendor would be inappropriate.
|
|
bindings = [binding for binding in bindings if
|
|
compatible_vnd(binding.compatible) != 'vnd']
|
|
|
|
logger.info('found %d bindings (ignored %d) in this dts_roots list: %s',
|
|
len(bindings), num_total - len(bindings), dts_roots)
|
|
|
|
return bindings
|
|
|
|
def load_base_binding():
|
|
# Make a Binding object for base.yaml.
|
|
#
|
|
# This helps separate presentation for properties common to all
|
|
# nodes from node-specific properties.
|
|
|
|
base_yaml = ZEPHYR_BASE / 'dts' / 'bindings' / 'base' / 'base.yaml'
|
|
if not base_yaml.is_file():
|
|
sys.exit(f'Expected to find base.yaml at {base_yaml}')
|
|
return edtlib.Binding(os.fspath(base_yaml), {}, require_compatible=False,
|
|
require_description=False)
|
|
|
|
def dump_content(bindings, base_binding, vnd_lookup, out_dir):
|
|
# Dump the generated .rst files for a vnd2bindings dict.
|
|
# Files are only written if they are changed. Existing .rst
|
|
# files which would not be written by the 'vnd2bindings'
|
|
# dict are removed.
|
|
|
|
out_dir = Path(out_dir)
|
|
|
|
setup_bindings_dir(bindings, out_dir)
|
|
write_bindings_rst(vnd_lookup, out_dir)
|
|
write_per_compatible_orphans(bindings, base_binding, vnd_lookup, out_dir)
|
|
|
|
def setup_bindings_dir(bindings, out_dir):
|
|
# Make a set of all the Path objects we will be creating for
|
|
# out_dir / bindings / {compatible}.rst. Delete all the ones that
|
|
# shouldn't be there. Make sure the bindings output directory
|
|
# exists.
|
|
|
|
paths = set()
|
|
bindings_dir = out_dir / 'bindings'
|
|
logger.info('making output subdirectory %s', bindings_dir)
|
|
bindings_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
for binding in bindings:
|
|
paths.add(bindings_dir / binding_filename(binding))
|
|
|
|
for path in bindings_dir.iterdir():
|
|
if path not in paths:
|
|
logger.info('removing unexpected file %s', path)
|
|
path.unlink()
|
|
|
|
def write_bindings_rst(vnd_lookup, out_dir):
|
|
# Write out_dir / bindings.rst, the top level index of bindings.
|
|
|
|
string_io = io.StringIO()
|
|
|
|
print_block(f'''\
|
|
.. _devicetree_binding_index:
|
|
|
|
Bindings index
|
|
##############
|
|
|
|
This page documents the available devicetree bindings.
|
|
See {zref('dt-bindings')} for an introduction to the Zephyr bindings
|
|
file format.
|
|
|
|
Vendor index
|
|
************
|
|
|
|
This section contains an index of hardware vendors.
|
|
Click on a vendor's name to go to the list of bindings for
|
|
that vendor.
|
|
|
|
.. rst-class:: rst-columns
|
|
''', string_io)
|
|
|
|
for vnd in vnd_lookup.vnd2bindings:
|
|
print(f'- :ref:`{vnd_lookup.target(vnd)}`', file=string_io)
|
|
|
|
print_block('''\
|
|
|
|
Bindings by vendor
|
|
******************
|
|
|
|
This section contains available bindings, grouped by vendor.
|
|
Within each group, bindings are listed by the "compatible" property
|
|
they apply to, like this:
|
|
|
|
**Vendor name (vendor prefix)**
|
|
|
|
.. rst-class:: rst-columns
|
|
|
|
- <compatible-A>
|
|
- <compatible-B> (on <bus-name> bus)
|
|
- <compatible-C>
|
|
- ...
|
|
|
|
The text "(on <bus-name> bus)" appears when bindings may behave
|
|
differently depending on the bus the node appears on.
|
|
For example, this applies to some sensor device nodes, which may
|
|
appear as children of either I2C or SPI bus nodes.
|
|
''', string_io)
|
|
|
|
for vnd, bindings in vnd_lookup.vnd2bindings.items():
|
|
if isinstance(vnd, tuple):
|
|
title = vnd[0]
|
|
else:
|
|
title = vnd_lookup.vendor(vnd).strip()
|
|
if isinstance(vnd, str):
|
|
title += f' ({vnd})'
|
|
underline = '=' * len(title)
|
|
|
|
print_block(f'''\
|
|
.. _{vnd_lookup.target(vnd)}:
|
|
|
|
{title}
|
|
{underline}
|
|
|
|
.. rst-class:: rst-columns
|
|
''', string_io)
|
|
for binding in bindings:
|
|
print(f'- :ref:`{binding_ref_target(binding)}`', file=string_io)
|
|
print(file=string_io)
|
|
|
|
write_if_updated(out_dir / 'bindings.rst', string_io.getvalue())
|
|
|
|
def write_per_compatible_orphans(bindings, base_binding, vnd_lookup, out_dir):
|
|
# For each compatible in vnd2bindings, write the corresponding
|
|
# out_dir / bindings / {binding.compatible}.rst file, if its
|
|
# content needs updating. This file is an 'orphan' because it's not
|
|
# in any toctree.
|
|
|
|
logging.info('updating up to %d :orphan: files', len(bindings))
|
|
num_written = 0
|
|
# Names of properties in base.yaml.
|
|
base_names = set(base_binding.prop2specs.keys())
|
|
for binding in bindings:
|
|
string_io = io.StringIO()
|
|
|
|
# :orphan:
|
|
#
|
|
# .. ref_target:
|
|
#
|
|
# Title [(on <bus> bus)]
|
|
# ######################
|
|
if binding.on_bus:
|
|
on_bus_title = f' (on {binding.on_bus} bus)'
|
|
else:
|
|
on_bus_title = ''
|
|
compatible = binding.compatible
|
|
title = f'{compatible}{on_bus_title}'
|
|
underline = '#' * len(title)
|
|
print_block(f'''\
|
|
:orphan:
|
|
|
|
.. _{binding_ref_target(binding)}:
|
|
|
|
{title}
|
|
{underline}
|
|
''', string_io)
|
|
|
|
# Vendor: <link-to-vendor-section>
|
|
vnd = compatible_vnd(compatible)
|
|
print('Vendor: '
|
|
f':ref:`{vnd_lookup.vendor(vnd)} <{vnd_lookup.target(vnd)}>`\n',
|
|
file=string_io)
|
|
|
|
# Binding description.
|
|
if binding.bus:
|
|
bus_help = f'These nodes are "{binding.bus}" bus nodes.'
|
|
else:
|
|
bus_help = ''
|
|
print_block(f'''\
|
|
Description
|
|
***********
|
|
|
|
{bus_help}
|
|
''', string_io)
|
|
print(to_code_block(binding.description.strip()), file=string_io)
|
|
|
|
# Properties.
|
|
print_block('''\
|
|
Properties
|
|
**********
|
|
''', string_io)
|
|
print_top_level_properties(binding, base_names, string_io)
|
|
print_child_binding_properties(binding, string_io)
|
|
|
|
# Specifier cells.
|
|
#
|
|
# This presentation isn't particularly nice. Perhaps something
|
|
# better can be done for future work.
|
|
if binding.specifier2cells:
|
|
print_block('''\
|
|
Specifier cell names
|
|
********************
|
|
''', string_io)
|
|
for specifier, cells in binding.specifier2cells.items():
|
|
print(f'- {specifier} cells: {", ".join(cells)}',
|
|
file=string_io)
|
|
|
|
written = write_if_updated(out_dir / 'bindings' /
|
|
binding_filename(binding),
|
|
string_io.getvalue())
|
|
|
|
if written:
|
|
num_written += 1
|
|
|
|
logging.info('done writing :orphan: files; %d files needed updates',
|
|
num_written)
|
|
|
|
def print_top_level_properties(binding, base_names, string_io):
|
|
# Print the RST for top level properties for 'binding' to 'string_io'.
|
|
#
|
|
# The 'base_names' set contains all the base.yaml properties.
|
|
|
|
def prop_table(filter_fn):
|
|
# Get a properly formatted and indented table of properties.
|
|
specs = [prop_spec for prop_spec in binding.prop2specs.values()
|
|
if filter_fn(prop_spec)]
|
|
indent = ' ' * 14
|
|
if specs:
|
|
temp_io = io.StringIO()
|
|
print_property_table(specs, temp_io)
|
|
return textwrap.indent(temp_io.getvalue(), indent)
|
|
|
|
return indent + '(None)'
|
|
|
|
if binding.child_binding:
|
|
print_block('''\
|
|
Top level properties
|
|
====================
|
|
''', string_io)
|
|
if binding.prop2specs:
|
|
if binding.child_binding:
|
|
print_block(f'''
|
|
These property descriptions apply to "{binding.compatible}"
|
|
nodes themselves. This page also describes child node
|
|
properties in the following sections.
|
|
''', string_io)
|
|
|
|
print_block(f'''\
|
|
.. tabs::
|
|
|
|
.. group-tab:: Node specific properties
|
|
|
|
Properties not inherited from the base binding file.
|
|
|
|
{prop_table(lambda prop_spec: prop_spec.name not in base_names)}
|
|
|
|
.. group-tab:: Base properties
|
|
|
|
Properties inherited from the base binding file, which defines
|
|
common properties that may be set on many nodes. Not all of these
|
|
may apply to the "{binding.compatible}" compatible.
|
|
|
|
{prop_table(lambda prop_spec: prop_spec.name in base_names)}
|
|
|
|
''', string_io)
|
|
else:
|
|
print('No top-level properties.\n', file=string_io)
|
|
|
|
def print_child_binding_properties(binding, string_io):
|
|
# Prints property tables for all levels of nesting of child
|
|
# bindings.
|
|
|
|
level = 1
|
|
child = binding.child_binding
|
|
while child is not None:
|
|
if level == 1:
|
|
level_string = 'Child'
|
|
elif level == 2:
|
|
level_string = 'Grandchild'
|
|
else:
|
|
level_string = f'Level {level} child'
|
|
if child.prop2specs:
|
|
title = f'{level_string} node properties'
|
|
underline = '=' * len(title)
|
|
print(f'{title}\n{underline}\n', file=string_io)
|
|
print_property_table(child.prop2specs.values(), string_io)
|
|
child = child.child_binding
|
|
level += 1
|
|
|
|
def print_property_table(prop_specs, string_io):
|
|
# Writes a table of properties based on 'prop_specs', an iterable
|
|
# of edtlib.PropertySpec objects, to 'string_io'.
|
|
|
|
# Table header.
|
|
print_block('''\
|
|
.. list-table::
|
|
:widths: 1 1 4
|
|
:header-rows: 1
|
|
|
|
* - Name
|
|
- Type
|
|
- Details
|
|
''', string_io)
|
|
|
|
def to_prop_table_row(prop_spec):
|
|
# Get a multiline string for a PropertySpec table row.
|
|
|
|
# The description column combines the description field,
|
|
# along with things like the default value or enum values.
|
|
#
|
|
# The property 'description' field from the binding may span
|
|
# one or multiple lines. We try to come up with a nice
|
|
# presentation for each.
|
|
details = ''
|
|
raw_prop_descr = prop_spec.description
|
|
if raw_prop_descr:
|
|
details += to_code_block(raw_prop_descr)
|
|
|
|
if prop_spec.required:
|
|
details += '\n\nThis property is **required**.'
|
|
|
|
if prop_spec.default:
|
|
details += f'\n\nDefault value: ``{prop_spec.default}``'
|
|
|
|
if prop_spec.const:
|
|
details += f'\n\nConstant value: ``{prop_spec.const}``'
|
|
elif prop_spec.enum:
|
|
details += ('\n\nLegal values: ' +
|
|
', '.join(f'``{repr(val)}``' for val in
|
|
prop_spec.enum))
|
|
|
|
if prop_spec.name in DETAILS_IN_IMPORTANT_PROPS:
|
|
details += (f'\n\nSee {zref("dt-important-props")} for more '
|
|
'information.')
|
|
|
|
return f"""\
|
|
* - ``{prop_spec.name}``
|
|
- ``{prop_spec.type}``
|
|
- {textwrap.indent(details, ' ' * 7).lstrip()}
|
|
"""
|
|
|
|
# Print each row.
|
|
for prop_spec in prop_specs:
|
|
print(to_prop_table_row(prop_spec), file=string_io)
|
|
|
|
def print_block(block, string_io):
|
|
# Helper for dedenting and printing a triple-quoted RST block.
|
|
# (Just a block of text, not necessarily just a 'code-block'
|
|
# directive.)
|
|
|
|
print(textwrap.dedent(block), file=string_io)
|
|
|
|
def to_code_block(s, indent=0):
|
|
# Converts 's', a string, to an indented rst .. code-block::. The
|
|
# 'indent' argument is a leading indent for each line in the code
|
|
# block, in spaces.
|
|
indent = indent * ' '
|
|
return ('.. code-block:: none\n\n' +
|
|
textwrap.indent(s, indent + ' ') + '\n')
|
|
|
|
def compatible_vnd(compatible):
|
|
# Get the vendor prefix for a compatible string 'compatible'.
|
|
#
|
|
# For example, compatible_vnd('foo,device') is 'foo'.
|
|
#
|
|
# If 'compatible' has no comma (','), None is returned.
|
|
|
|
if ',' not in compatible:
|
|
return None
|
|
|
|
return compatible.split(',', 1)[0]
|
|
|
|
def binding_ref_target(binding):
|
|
# Return the sphinx ':ref:' target name for a binding.
|
|
|
|
ret = re.sub('[,-]', '_', binding.compatible)
|
|
if binding.on_bus is not None:
|
|
ret += f'_{binding.on_bus}'
|
|
return ret
|
|
|
|
def zref(target, text=None):
|
|
# Make an appropriate RST :ref:`text <target>` or :ref:`target`
|
|
# string to a zephyr documentation ref target 'target', and return
|
|
# it.
|
|
#
|
|
# By default, the bindings docs are in the main Zephyr
|
|
# documentation, but this script supports putting them in a
|
|
# separate Sphinx doc set. Since we also link to Zephyr
|
|
# documentation from the generated content, we have an environment
|
|
# variable based escape hatch for putting the target in the zephyr
|
|
# doc set.
|
|
#
|
|
# This relies on intersphinx:
|
|
# https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html
|
|
|
|
docset = os.environ.get('GEN_DEVICETREE_REST_ZEPHYR_DOCSET', '')
|
|
|
|
if docset.strip():
|
|
target = f'{docset}:{target}'
|
|
|
|
if text:
|
|
return f':ref:`{text} <{target}>`'
|
|
|
|
return f':ref:`{target}`'
|
|
|
|
def binding_filename(binding):
|
|
if binding.on_bus is None:
|
|
return f'{binding.compatible}.rst'
|
|
|
|
return f'{binding.compatible}-{binding.on_bus}.rst'
|
|
|
|
def write_if_updated(path, s):
|
|
# gen_helpers.write_if_updated() wrapper that handles logging.
|
|
|
|
written = gen_helpers.write_if_updated(path, s)
|
|
logger.debug('%s %s', 'wrote' if written else 'did NOT write', path)
|
|
return written
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|
|
sys.exit(0)
|