зеркало из https://github.com/mozilla/gecko-dev.git
Bug 1779473 - Add TOML support to manifestparser r=jmaher,ahal
ManifestParser will read TOML files, if present, when use_toml=True Added tomlkit as a third_party python package Added poetry-core and tomlkit to pypi (separately as Bug 1845383, Bug 1844787) Adds TOML test coverage Adds tomlkit as a dependency of mozharness (in test_archive.py) Added tomlkit to virtualenv_modules in testing/mozharness/configs/unittests Removes dependency on six testing/tools/mach_test_package_initialize.py - Corrected SEARCH_PATHS testing/mozharness/mozharness/mozilla/testing/per_test_base.py - moved `from manifestparser import TestManifest` into function call to avoid harness inability to locate the internal artifact - Removed linter warnings testing/mozbase/manifestparser/manifestparser/manifestparser.py - Removed linter warnings - Updated logger usage pattern - Simplifed _read logic, refactored get_fp_filename() - Improve context for `include:` logging message - Defer `import mozlog` until the point of use testing/mozbase/manifestparser/manifestparser/toml.py - Removed linter warnings - Removed unused logger - Improved readability of read_toml() testing/mozbase/manifestparser/manifestparser/ini.py - Removed linter warnings - Removed unused logger testing/mozbase/manifestparser/manifestparser/filters.py - Removed linter warnings testing/mozbase/manifestparser/tests/test_chunking.py - Removed linter warnings Bumped manifestparser version to 2.2.31 Differential Revision: https://phabricator.services.mozilla.com/D184020
This commit is contained in:
Родитель
bbdc4e1f0a
Коммит
b51988220c
|
@ -313,6 +313,16 @@ ARCHIVE_FILES = {
|
|||
"base": "testing/mozbase/mozinfo",
|
||||
"pattern": "mozinfo/**",
|
||||
},
|
||||
{
|
||||
"source": buildconfig.topsrcdir,
|
||||
"base": "testing/mozbase/mozlog",
|
||||
"pattern": "mozlog/**",
|
||||
},
|
||||
{
|
||||
"source": buildconfig.topsrcdir,
|
||||
"base": "python/mozterm",
|
||||
"pattern": "mozterm/**",
|
||||
},
|
||||
{
|
||||
"source": buildconfig.topsrcdir,
|
||||
"base": "testing/mozbase/mozprocess",
|
||||
|
@ -323,6 +333,11 @@ ARCHIVE_FILES = {
|
|||
"base": "third_party/python/six",
|
||||
"pattern": "six.py",
|
||||
},
|
||||
{
|
||||
"source": buildconfig.topsrcdir,
|
||||
"base": "third_party/python/tomlkit",
|
||||
"pattern": "**",
|
||||
},
|
||||
{
|
||||
"source": buildconfig.topsrcdir,
|
||||
"base": "third_party/python/distro",
|
||||
|
|
|
@ -25,8 +25,6 @@ vendored:third_party/python/fluent.syntax
|
|||
vendored:third_party/python/giturlparse
|
||||
vendored:third_party/python/glean_parser
|
||||
vendored:third_party/python/gyp/pylib
|
||||
vendored:third_party/python/importlib_metadata
|
||||
vendored:third_party/python/importlib_resources
|
||||
vendored:third_party/python/jinja2_time
|
||||
vendored:third_party/python/json-e
|
||||
vendored:third_party/python/jsonschema
|
||||
|
@ -51,8 +49,6 @@ vendored:third_party/python/taskcluster_taskgraph
|
|||
vendored:third_party/python/taskcluster_urls
|
||||
vendored:third_party/python/text_unidecode
|
||||
vendored:third_party/python/toml
|
||||
vendored:third_party/python/typing_extensions
|
||||
vendored:third_party/python/voluptuous
|
||||
vendored:third_party/python/yamllint
|
||||
vendored:third_party/python/yarl
|
||||
vendored:third_party/python/zipp
|
||||
|
|
|
@ -28,8 +28,6 @@ vendored:third_party/python/fluent.syntax
|
|||
vendored:third_party/python/giturlparse
|
||||
vendored:third_party/python/glean_parser
|
||||
vendored:third_party/python/gyp/pylib
|
||||
vendored:third_party/python/importlib_metadata
|
||||
vendored:third_party/python/importlib_resources
|
||||
vendored:third_party/python/jinja2_time
|
||||
vendored:third_party/python/json-e
|
||||
vendored:third_party/python/jsonschema
|
||||
|
@ -56,8 +54,6 @@ vendored:third_party/python/taskcluster_taskgraph
|
|||
vendored:third_party/python/taskcluster_urls
|
||||
vendored:third_party/python/text_unidecode
|
||||
vendored:third_party/python/toml
|
||||
vendored:third_party/python/typing_extensions
|
||||
vendored:third_party/python/voluptuous
|
||||
vendored:third_party/python/yamllint
|
||||
vendored:third_party/python/yarl
|
||||
vendored:third_party/python/zipp
|
||||
|
|
|
@ -58,8 +58,6 @@ vendored:third_party/python/chardet
|
|||
vendored:third_party/python/cookiecutter
|
||||
vendored:third_party/python/fluent.syntax
|
||||
vendored:third_party/python/giturlparse
|
||||
vendored:third_party/python/importlib_metadata
|
||||
vendored:third_party/python/importlib_resources
|
||||
vendored:third_party/python/jinja2_time
|
||||
vendored:third_party/python/json-e
|
||||
vendored:third_party/python/jsonschema
|
||||
|
@ -84,4 +82,3 @@ vendored:third_party/python/text_unidecode
|
|||
vendored:third_party/python/toml
|
||||
vendored:third_party/python/urllib3
|
||||
vendored:third_party/python/voluptuous
|
||||
vendored:third_party/python/zipp
|
|
@ -29,7 +29,6 @@ vendored:third_party/python/taskcluster_taskgraph
|
|||
vendored:third_party/python/taskcluster_urls
|
||||
vendored:third_party/python/text_unidecode
|
||||
vendored:third_party/python/toml
|
||||
vendored:third_party/python/typing_extensions
|
||||
vendored:third_party/python/voluptuous
|
||||
vendored:third_party/python/yamllint
|
||||
vendored:third_party/python/yarl
|
||||
|
|
|
@ -65,6 +65,8 @@ vendored:third_party/python/click
|
|||
vendored:third_party/python/colorama
|
||||
vendored:third_party/python/distro
|
||||
vendored:third_party/python/idna
|
||||
vendored:third_party/python/importlib_metadata
|
||||
vendored:third_party/python/importlib_resources
|
||||
vendored:third_party/python/jinxed
|
||||
vendored:third_party/python/jsmin
|
||||
vendored:third_party/python/looseversion
|
||||
|
@ -78,10 +80,13 @@ vendored:third_party/python/requests
|
|||
vendored:third_party/python/sentry_sdk
|
||||
vendored:third_party/python/setuptools
|
||||
vendored:third_party/python/six
|
||||
vendored:third_party/python/tomlkit
|
||||
vendored:third_party/python/tqdm
|
||||
vendored:third_party/python/typing_extensions
|
||||
vendored:third_party/python/urllib3
|
||||
vendored:third_party/python/wcwidth
|
||||
vendored:third_party/python/wheel
|
||||
vendored:third_party/python/zipp
|
||||
# glean-sdk may not be installable if a wheel isn't available
|
||||
# and it has to be built from source.
|
||||
pypi-optional:glean-sdk==53.1.0:telemetry will not be collected
|
||||
|
|
|
@ -3,8 +3,7 @@ pypi:coverage==5.1
|
|||
vendored:third_party/python/PyYAML/lib/
|
||||
vendored:third_party/python/dlmanager
|
||||
vendored:third_party/python/esprima
|
||||
vendored:third_party/python/importlib_resources
|
||||
vendored:third_party/python/jsonschema
|
||||
vendored:third_party/python/pyrsistent
|
||||
vendored:third_party/python/redo
|
||||
vendored:third_party/python/responses
|
||||
vendored:third_party/python/responses
|
||||
|
|
|
@ -29,8 +29,6 @@ vendored:third_party/python/fluent.syntax
|
|||
vendored:third_party/python/giturlparse
|
||||
vendored:third_party/python/glean_parser
|
||||
vendored:third_party/python/gyp/pylib
|
||||
vendored:third_party/python/importlib_metadata
|
||||
vendored:third_party/python/importlib_resources
|
||||
vendored:third_party/python/jinja2_time
|
||||
vendored:third_party/python/json-e
|
||||
vendored:third_party/python/jsonschema
|
||||
|
@ -57,8 +55,6 @@ vendored:third_party/python/taskcluster_taskgraph
|
|||
vendored:third_party/python/taskcluster_urls
|
||||
vendored:third_party/python/text_unidecode
|
||||
vendored:third_party/python/toml
|
||||
vendored:third_party/python/typing_extensions
|
||||
vendored:third_party/python/voluptuous
|
||||
vendored:third_party/python/yamllint
|
||||
vendored:third_party/python/yarl
|
||||
vendored:third_party/python/zipp
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
../tools/mozterm
|
||||
../tools/geckoprocesstypes_generator
|
||||
../tools/mozterm
|
||||
|
||||
../mozbase/manifestparser
|
||||
../mozbase/mozcrash
|
||||
|
@ -28,4 +28,5 @@ aiohttp==3.7.4.post0; sys_platform != 'darwin'
|
|||
https://pypi.pub.build.mozilla.org/pub/arsenic-19.1-py3-none-any.whl; sys_platform != 'darwin'
|
||||
requests==2.22.0; sys_platform != 'darwin'
|
||||
pyyaml==5.1.2; sys_platform != 'darwin'
|
||||
structlog==15.2.0; sys_platform != 'darwin'
|
||||
structlog==15.2.0; sys_platform != 'darwin'
|
||||
tomlkit==0.11.8
|
||||
|
|
|
@ -6,8 +6,6 @@ import re
|
|||
import sys
|
||||
import traceback
|
||||
|
||||
import six
|
||||
|
||||
__all__ = ["parse", "ParseError", "ExpressionParser"]
|
||||
|
||||
# expr.py
|
||||
|
@ -276,7 +274,7 @@ class ExpressionParser(object):
|
|||
"""
|
||||
if not isinstance(self.token, expected):
|
||||
raise Exception("Unexpected token!")
|
||||
self.token = six.next(self.iter)
|
||||
self.token = next(self.iter)
|
||||
|
||||
def expression(self, rbp=0):
|
||||
"""
|
||||
|
@ -284,11 +282,11 @@ class ExpressionParser(object):
|
|||
right binding power greater than rbp is encountered.
|
||||
"""
|
||||
t = self.token
|
||||
self.token = six.next(self.iter)
|
||||
self.token = next(self.iter)
|
||||
left = t.nud(self)
|
||||
while rbp < self.token.lbp:
|
||||
t = self.token
|
||||
self.token = six.next(self.iter)
|
||||
self.token = next(self.iter)
|
||||
left = t.led(self, left)
|
||||
return left
|
||||
|
||||
|
@ -300,19 +298,16 @@ class ExpressionParser(object):
|
|||
"""
|
||||
try:
|
||||
self.iter = self._tokenize()
|
||||
self.token = six.next(self.iter)
|
||||
self.token = next(self.iter)
|
||||
return self.expression()
|
||||
except Exception:
|
||||
extype, ex, tb = sys.exc_info()
|
||||
formatted = "".join(traceback.format_exception_only(extype, ex))
|
||||
six.reraise(
|
||||
ParseError,
|
||||
ParseError(
|
||||
"could not parse: %s\nexception: %svariables: %s"
|
||||
% (self.text, formatted, self.valuemapping)
|
||||
),
|
||||
tb,
|
||||
pe = ParseError(
|
||||
"could not parse: %s\nexception: %svariables: %s"
|
||||
% (self.text, formatted, self.valuemapping)
|
||||
)
|
||||
raise pe.with_traceback(tb)
|
||||
|
||||
__call__ = parse
|
||||
|
||||
|
|
|
@ -13,25 +13,9 @@ import os
|
|||
from collections import defaultdict
|
||||
from collections.abc import MutableSequence
|
||||
|
||||
import six
|
||||
from six import string_types
|
||||
|
||||
from .expression import ParseError, parse
|
||||
from .util import normsep
|
||||
|
||||
logger = None
|
||||
|
||||
|
||||
def log(msg, level="info"):
|
||||
from mozlog import get_default_logger
|
||||
|
||||
global logger
|
||||
if not logger:
|
||||
logger = get_default_logger(component="manifestparser")
|
||||
if logger:
|
||||
getattr(logger, level)(msg)
|
||||
|
||||
|
||||
# built-in filters
|
||||
|
||||
|
||||
|
@ -118,7 +102,7 @@ class InstanceFilter(object):
|
|||
self.fmt_args = ", ".join(
|
||||
itertools.chain(
|
||||
[str(a) for a in args],
|
||||
["{}={}".format(k, v) for k, v in six.iteritems(kwargs)],
|
||||
["{}={}".format(k, v) for k, v in kwargs.items()],
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -170,9 +154,8 @@ class subsuite(InstanceFilter):
|
|||
if self.name is None:
|
||||
if not test.get("subsuite"):
|
||||
yield test
|
||||
else:
|
||||
if test.get("subsuite", "") == self.name:
|
||||
yield test
|
||||
elif test.get("subsuite", "") == self.name:
|
||||
yield test
|
||||
|
||||
|
||||
class chunk_by_slice(InstanceFilter):
|
||||
|
@ -279,7 +262,7 @@ class chunk_by_dir(InstanceFilter):
|
|||
# simplicity.
|
||||
if self.this_chunk == 1:
|
||||
disabled_dirs = [
|
||||
v for k, v in six.iteritems(tests_by_dir) if k not in ordered_dirs
|
||||
v for k, v in tests_by_dir.items() if k not in ordered_dirs
|
||||
]
|
||||
for disabled_test in itertools.chain(*disabled_dirs):
|
||||
yield disabled_test
|
||||
|
@ -341,6 +324,12 @@ class chunk_by_runtime(InstanceFilter):
|
|||
self.this_chunk = this_chunk
|
||||
self.total_chunks = total_chunks
|
||||
self.runtimes = {normsep(m): r for m, r in runtimes.items()}
|
||||
component = "filters"
|
||||
import mozlog
|
||||
|
||||
self.logger = mozlog.get_default_logger(component)
|
||||
if self.logger is None:
|
||||
self.logger = mozlog.unstructured.getLogger(component)
|
||||
|
||||
@classmethod
|
||||
def get_manifest(cls, test):
|
||||
|
@ -364,7 +353,7 @@ class chunk_by_runtime(InstanceFilter):
|
|||
# pylint: disable=W1633
|
||||
avg = round(sum(times) / len(times), 2) if times else 0
|
||||
missing = sorted([m for m in manifests if m not in self.runtimes])
|
||||
log(
|
||||
self.logger.info(
|
||||
"Applying average runtime of {}s to the following missing manifests:\n{}".format(
|
||||
avg, " " + "\n ".join(missing)
|
||||
)
|
||||
|
@ -394,7 +383,7 @@ class chunk_by_runtime(InstanceFilter):
|
|||
runtime, this_manifests = chunks[self.this_chunk - 1]
|
||||
# pylint --py3k W1619
|
||||
# pylint: disable=W1633
|
||||
log(
|
||||
self.logger.info(
|
||||
"Cumulative test runtime is around {} minutes (average is {} minutes)".format(
|
||||
round(runtime / 60),
|
||||
round(sum([c[0] for c in chunks]) / (60 * len(chunks))),
|
||||
|
@ -423,7 +412,7 @@ class tags(InstanceFilter):
|
|||
|
||||
def __init__(self, tags):
|
||||
InstanceFilter.__init__(self, tags)
|
||||
if isinstance(tags, string_types):
|
||||
if isinstance(tags, str):
|
||||
tags = [tags]
|
||||
self.tags = tags
|
||||
|
||||
|
@ -477,7 +466,7 @@ class pathprefix(InstanceFilter):
|
|||
|
||||
def __init__(self, paths):
|
||||
InstanceFilter.__init__(self, paths)
|
||||
if isinstance(paths, string_types):
|
||||
if isinstance(paths, str):
|
||||
paths = [paths]
|
||||
self.paths = paths
|
||||
self.missing = set()
|
||||
|
@ -485,8 +474,8 @@ class pathprefix(InstanceFilter):
|
|||
def __call__(self, tests, values):
|
||||
seen = set()
|
||||
for test in tests:
|
||||
for tp in self.paths:
|
||||
tp = os.path.normpath(tp)
|
||||
for testpath in self.paths:
|
||||
tp = os.path.normpath(testpath)
|
||||
|
||||
if tp.endswith(".ini"):
|
||||
mpaths = [test["manifest_relpath"]]
|
||||
|
|
|
@ -6,14 +6,12 @@ import io
|
|||
import os
|
||||
import sys
|
||||
|
||||
from six import string_types
|
||||
|
||||
__all__ = ["read_ini", "combine_fields"]
|
||||
|
||||
|
||||
class IniParseError(Exception):
|
||||
def __init__(self, fp, linenum, msg):
|
||||
if isinstance(fp, string_types):
|
||||
if isinstance(fp, str):
|
||||
path = fp
|
||||
elif hasattr(fp, "name"):
|
||||
path = fp.name
|
||||
|
@ -51,11 +49,14 @@ def read_ini(
|
|||
sections = []
|
||||
key = value = None
|
||||
section_names = set()
|
||||
if isinstance(fp, string_types):
|
||||
if isinstance(fp, str):
|
||||
fp = io.open(fp, encoding="utf-8")
|
||||
|
||||
# read the lines
|
||||
section = default
|
||||
current_section = {}
|
||||
current_section_name = ""
|
||||
key_indent = 0
|
||||
for (linenum, line) in enumerate(fp.read().splitlines(), start=1):
|
||||
|
||||
stripped = line.strip()
|
||||
|
@ -75,8 +76,8 @@ def read_ini(
|
|||
inline_prefixes = {p: -1 for p in comments}
|
||||
while comment_start == sys.maxsize and inline_prefixes:
|
||||
next_prefixes = {}
|
||||
for prefix, index in inline_prefixes.items():
|
||||
index = stripped.find(prefix, index + 1)
|
||||
for prefix, i in inline_prefixes.items():
|
||||
index = stripped.find(prefix, i + 1)
|
||||
if index == -1:
|
||||
continue
|
||||
next_prefixes[prefix] = index
|
||||
|
@ -90,7 +91,8 @@ def read_ini(
|
|||
# check for a new section
|
||||
if len(stripped) > 2 and stripped[0] == "[" and stripped[-1] == "]":
|
||||
section = stripped[1:-1].strip()
|
||||
key = value = key_indent = None
|
||||
key = value = None
|
||||
key_indent = 0
|
||||
|
||||
# deal with DEFAULT section
|
||||
if section.lower() == default.lower():
|
||||
|
|
|
@ -10,12 +10,12 @@ import os
|
|||
import shutil
|
||||
import sys
|
||||
import types
|
||||
|
||||
from six import StringIO, string_types
|
||||
from io import StringIO
|
||||
|
||||
from .filters import DEFAULT_FILTERS, enabled, filterlist
|
||||
from .filters import exists as _exists
|
||||
from .ini import read_ini
|
||||
from .toml import read_toml
|
||||
|
||||
__all__ = ["ManifestParser", "TestManifest", "convert"]
|
||||
|
||||
|
@ -53,6 +53,7 @@ class ManifestParser(object):
|
|||
rootdir=None,
|
||||
finder=None,
|
||||
handle_defaults=True,
|
||||
use_toml=False,
|
||||
):
|
||||
"""Creates a ManifestParser from the given manifest files.
|
||||
|
||||
|
@ -77,6 +78,7 @@ class ManifestParser(object):
|
|||
test objects. Callers are expected to manage per-manifest
|
||||
defaults themselves via the manifest_defaults member
|
||||
variable in this case.
|
||||
:param use_toml: If True *.toml configration files will be used iff present in the same location as *.ini files (applies to included files as well). If False only *.ini files will be considered. (defaults to True)
|
||||
"""
|
||||
self._defaults = defaults or {}
|
||||
self.tests = []
|
||||
|
@ -87,6 +89,13 @@ class ManifestParser(object):
|
|||
self._root = None
|
||||
self.finder = finder
|
||||
self._handle_defaults = handle_defaults
|
||||
self.use_toml = use_toml
|
||||
component = "manifestparser"
|
||||
import mozlog
|
||||
|
||||
self.logger = mozlog.get_default_logger(component)
|
||||
if self.logger is None:
|
||||
self.logger = mozlog.unstructured.getLogger(component)
|
||||
if manifests:
|
||||
self.read(*manifests)
|
||||
|
||||
|
@ -124,6 +133,27 @@ class ManifestParser(object):
|
|||
return relpath(path, self.root)
|
||||
|
||||
# methods for reading manifests
|
||||
def _get_fp_filename(self, filename):
|
||||
# get directory of this file if not file-like object
|
||||
if isinstance(filename, str):
|
||||
# If we're using mercurial as our filesystem via a finder
|
||||
# during manifest reading, the getcwd() calls that happen
|
||||
# with abspath calls will not be meaningful, so absolute
|
||||
# paths are required.
|
||||
if self.finder:
|
||||
assert os.path.isabs(filename)
|
||||
filename = os.path.abspath(filename)
|
||||
if self.finder:
|
||||
fp = codecs.getreader("utf-8")(self.finder.get(filename).open())
|
||||
else:
|
||||
fp = io.open(filename, encoding="utf-8")
|
||||
else:
|
||||
fp = filename
|
||||
if hasattr(fp, "name"):
|
||||
filename = os.path.abspath(fp.name)
|
||||
else:
|
||||
filename = None
|
||||
return fp, filename
|
||||
|
||||
def _read(self, root, filename, defaults, parentmanifest=None):
|
||||
"""
|
||||
|
@ -140,6 +170,20 @@ class ManifestParser(object):
|
|||
include_file = normalize_path(include_file)
|
||||
if not os.path.isabs(include_file):
|
||||
include_file = os.path.join(here, include_file)
|
||||
file_base, file_ext = os.path.splitext(include_file)
|
||||
if file_ext == ".ini":
|
||||
toml_name = file_base + ".toml"
|
||||
if self.path_exists(toml_name):
|
||||
if self.use_toml:
|
||||
include_file = toml_name
|
||||
else:
|
||||
self.logger.debug(
|
||||
f"NOTE TOML include file present, but not used: {toml_name}"
|
||||
)
|
||||
elif file_ext != ".toml":
|
||||
raise IOError(
|
||||
f"manfestparser file extension not supported: {include_file}"
|
||||
)
|
||||
if not self.path_exists(include_file):
|
||||
message = "Included file '%s' does not exist" % include_file
|
||||
if self.strict:
|
||||
|
@ -149,30 +193,41 @@ class ManifestParser(object):
|
|||
return
|
||||
return include_file
|
||||
|
||||
# get directory of this file if not file-like object
|
||||
if isinstance(filename, string_types):
|
||||
# If we're using mercurial as our filesystem via a finder
|
||||
# during manifest reading, the getcwd() calls that happen
|
||||
# with abspath calls will not be meaningful, so absolute
|
||||
# paths are required.
|
||||
if self.finder:
|
||||
assert os.path.isabs(filename)
|
||||
filename = os.path.abspath(filename)
|
||||
filename_rel = self.relative_to_root(filename)
|
||||
self.source_files.add(filename)
|
||||
if self.finder:
|
||||
fp = codecs.getreader("utf-8")(self.finder.get(filename).open())
|
||||
else:
|
||||
fp = io.open(filename, encoding="utf-8")
|
||||
here = os.path.dirname(filename)
|
||||
else:
|
||||
fp = filename
|
||||
filename = here = None
|
||||
# assume we are reading an INI file
|
||||
read_fn = read_ini
|
||||
fp, filename = self._get_fp_filename(filename)
|
||||
if filename is None:
|
||||
filename_rel = None
|
||||
here = root
|
||||
file_base = file_ext = None
|
||||
else:
|
||||
self.source_files.add(filename)
|
||||
filename_rel = self.relative_to_root(filename)
|
||||
here = os.path.dirname(filename)
|
||||
file_base, file_ext = os.path.splitext(filename)
|
||||
if file_ext == ".ini":
|
||||
toml_name = file_base + ".toml"
|
||||
if self.path_exists(toml_name):
|
||||
if self.use_toml:
|
||||
fp, filename = self._get_fp_filename(toml_name)
|
||||
read_fn = read_toml
|
||||
self.logger.debug(f"Reading TOML: {filename}")
|
||||
else:
|
||||
self.logger.debug(
|
||||
f"NOTE TOML present, but not used: {toml_name}"
|
||||
)
|
||||
self.logger.debug(f"Reading INI: {filename}")
|
||||
else:
|
||||
self.logger.debug(f"Reading INI: {filename}")
|
||||
elif file_ext == ".toml":
|
||||
read_fn = read_toml
|
||||
self.logger.debug(f"Reading TOML: {filename}")
|
||||
else:
|
||||
raise IOError(f"manfestparser file extension not supported: {filename}")
|
||||
defaults["here"] = here
|
||||
|
||||
# read the configuration
|
||||
sections, defaults = read_ini(
|
||||
sections, defaults = read_fn(
|
||||
fp=fp,
|
||||
defaults=defaults,
|
||||
strict=self.strict,
|
||||
|
@ -199,6 +254,7 @@ class ManifestParser(object):
|
|||
# TODO: keep track of included file structure:
|
||||
# self.manifests = {'manifest.ini': 'relative/path.ini'}
|
||||
if section.startswith("include:"):
|
||||
self.logger.debug(f"ManifestParser, INCLUDE: {section}")
|
||||
include_file = read_file("include:")
|
||||
if include_file:
|
||||
include_defaults = data.copy()
|
||||
|
@ -260,7 +316,7 @@ class ManifestParser(object):
|
|||
missing = [
|
||||
filename
|
||||
for filename in filenames
|
||||
if isinstance(filename, string_types) and not self.path_exists(filename)
|
||||
if isinstance(filename, str) and not self.path_exists(filename)
|
||||
]
|
||||
if missing:
|
||||
raise IOError("Missing files: %s" % ", ".join(missing))
|
||||
|
@ -274,8 +330,11 @@ class ManifestParser(object):
|
|||
# set the per file defaults
|
||||
defaults = _defaults.copy()
|
||||
here = None
|
||||
if isinstance(filename, string_types):
|
||||
if isinstance(filename, str):
|
||||
here = os.path.dirname(os.path.abspath(filename))
|
||||
elif hasattr(filename, "name"):
|
||||
here = os.path.dirname(os.path.abspath(filename.name))
|
||||
if here:
|
||||
defaults["here"] = here # directory of master .ini file
|
||||
|
||||
if self.rootdir is None:
|
||||
|
@ -359,9 +418,11 @@ class ManifestParser(object):
|
|||
if tests is None:
|
||||
manifests = []
|
||||
# Make sure to return all the manifests, even ones without tests.
|
||||
for manifest in list(self.manifest_defaults.keys()):
|
||||
if isinstance(manifest, tuple):
|
||||
parentmanifest, manifest = manifest
|
||||
for m in list(self.manifest_defaults.keys()):
|
||||
if isinstance(m, tuple):
|
||||
_parentmanifest, manifest = m
|
||||
else:
|
||||
manifest = m
|
||||
if manifest not in manifests:
|
||||
manifests.append(manifest)
|
||||
return manifests
|
||||
|
@ -414,13 +475,13 @@ class ManifestParser(object):
|
|||
"""
|
||||
|
||||
files = set([])
|
||||
if isinstance(directories, string_types):
|
||||
if isinstance(directories, str):
|
||||
directories = [directories]
|
||||
|
||||
# get files in directories
|
||||
for directory in directories:
|
||||
for dirpath, dirnames, filenames in os.walk(directory, topdown=True):
|
||||
|
||||
for dirpath, _dirnames, fnames in os.walk(directory, topdown=True):
|
||||
filenames = fnames
|
||||
# only add files that match a pattern
|
||||
if pattern:
|
||||
filenames = fnmatch.filter(filenames, pattern)
|
||||
|
@ -462,7 +523,7 @@ class ManifestParser(object):
|
|||
|
||||
# open file if `fp` given as string
|
||||
close = False
|
||||
if isinstance(fp, string_types):
|
||||
if isinstance(fp, str):
|
||||
fp = open(fp, "w")
|
||||
close = True
|
||||
|
||||
|
@ -496,8 +557,8 @@ class ManifestParser(object):
|
|||
print("%s = %s" % (key, value), file=fp)
|
||||
print(file=fp)
|
||||
|
||||
for test in tests:
|
||||
test = test.copy() # don't overwrite
|
||||
for t in tests:
|
||||
test = t.copy() # don't overwrite
|
||||
|
||||
path = test["name"]
|
||||
if not os.path.isabs(path):
|
||||
|
@ -626,7 +687,7 @@ class ManifestParser(object):
|
|||
internal function to import directories
|
||||
"""
|
||||
|
||||
if isinstance(pattern, string_types):
|
||||
if isinstance(pattern, str):
|
||||
patterns = [pattern]
|
||||
else:
|
||||
patterns = pattern
|
||||
|
@ -763,7 +824,7 @@ class ManifestParser(object):
|
|||
# determine output
|
||||
opened_manifest_file = None # name of opened manifest file
|
||||
absolute = not relative_to # whether to output absolute path names as names
|
||||
if isinstance(write, string_types):
|
||||
if isinstance(write, str):
|
||||
opened_manifest_file = write
|
||||
write = open(write, "w")
|
||||
if write is None:
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
# This Source Code Form is subject to the terms of the Mozilla Public
|
||||
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
# You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
from tomlkit import parse
|
||||
from tomlkit.exceptions import ParseError
|
||||
from tomlkit.items import Array
|
||||
|
||||
from .ini import combine_fields
|
||||
|
||||
__all__ = ["read_toml"]
|
||||
|
||||
|
||||
def read_toml(
|
||||
fp,
|
||||
defaults=None,
|
||||
default="DEFAULT",
|
||||
comments=None,
|
||||
separators=None,
|
||||
strict=True,
|
||||
handle_defaults=True,
|
||||
):
|
||||
"""
|
||||
read a .toml file and return a list of [(section, values)]
|
||||
- fp : file pointer or path to read
|
||||
- defaults : default set of variables
|
||||
- default : name of the section for the default section
|
||||
- comments : characters that if they start a line denote a comment
|
||||
- separators : strings that denote key, value separation in order
|
||||
- strict : whether to be strict about parsing
|
||||
- handle_defaults : whether to incorporate defaults into each section
|
||||
"""
|
||||
|
||||
# variables
|
||||
defaults = defaults or {}
|
||||
default_section = {}
|
||||
comments = comments or ("#",)
|
||||
separators = separators or ("=", ":")
|
||||
sections = []
|
||||
if isinstance(fp, str):
|
||||
filename = fp
|
||||
fp = io.open(fp, encoding="utf-8")
|
||||
elif hasattr(fp, "name"):
|
||||
filename = fp.name
|
||||
else:
|
||||
filename = "unknown"
|
||||
contents = fp.read()
|
||||
|
||||
# Use tomlkit to parse the file contents
|
||||
try:
|
||||
manifest = parse(contents)
|
||||
except ParseError as pe:
|
||||
raise IOError(f"Error parsing TOML manifest file {filename}: {pe}")
|
||||
|
||||
# handle each section of the manifest
|
||||
for section in manifest.keys():
|
||||
current_section = {}
|
||||
for key in manifest[section].keys():
|
||||
val = manifest[section][key]
|
||||
if isinstance(val, bool):
|
||||
if val:
|
||||
val = "true"
|
||||
else:
|
||||
val = "false"
|
||||
elif isinstance(val, Array):
|
||||
new_val = "" # stay bug-for-bug compatible witn INI processing
|
||||
for v in val:
|
||||
new_val += os.linesep + str(v)
|
||||
val = new_val
|
||||
else:
|
||||
val = str(val) # coerce to str
|
||||
if " = " in val:
|
||||
raise Exception(
|
||||
f"Should not assign in {key} condition for {section}"
|
||||
)
|
||||
current_section[key] = val
|
||||
if section.lower() == default.lower():
|
||||
default_section = current_section
|
||||
# DEFAULT does NOT appear in the output
|
||||
else:
|
||||
sections.append((section, current_section))
|
||||
|
||||
# merge global defaults with the DEFAULT section
|
||||
defaults = combine_fields(defaults, default_section)
|
||||
if handle_defaults:
|
||||
# merge combined defaults into each section
|
||||
sections = [(i, combine_fields(defaults, j)) for i, j in sections]
|
||||
|
||||
return sections, defaults
|
|
@ -5,11 +5,11 @@
|
|||
from setuptools import setup
|
||||
|
||||
PACKAGE_NAME = "manifestparser"
|
||||
PACKAGE_VERSION = "2.1.0"
|
||||
PACKAGE_VERSION = "2.2.0"
|
||||
|
||||
DEPS = [
|
||||
"mozlog >= 6.0",
|
||||
"six >= 1.13.0",
|
||||
"tomlkit >= 0.11.8",
|
||||
]
|
||||
setup(
|
||||
name=PACKAGE_NAME,
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
[DEFAULT]
|
||||
skip-if = ''' os = "win" '''
|
|
@ -0,0 +1,11 @@
|
|||
# See https://bugzilla.mozilla.org/show_bug.cgi?id=813674
|
||||
|
||||
['test_0180_fileInUse_xp_win_complete.js']
|
||||
['test_0181_fileInUse_xp_win_partial.js']
|
||||
['test_0182_rmrfdirFileInUse_xp_win_complete.js']
|
||||
['test_0183_rmrfdirFileInUse_xp_win_partial.js']
|
||||
['test_0184_fileInUse_xp_win_complete.js']
|
||||
['test_0185_fileInUse_xp_win_partial.js']
|
||||
['test_0186_rmrfdirFileInUse_xp_win_complete.js']
|
||||
['test_0187_rmrfdirFileInUse_xp_win_partial.js']
|
||||
# [test_0202_app_launch_apply_update_dirlocked.js] # Test disabled, bug 757632
|
|
@ -0,0 +1,22 @@
|
|||
[DEFAULT]
|
||||
skip-if = ''' os == 'win' && debug ''' # a pesky comment
|
||||
|
||||
|
||||
[test1]
|
||||
skip-if = ''' debug '''
|
||||
|
||||
[test2]
|
||||
skip-if = ''' os == 'linux' '''
|
||||
|
||||
[test3]
|
||||
skip-if = ''' os == 'win' '''
|
||||
|
||||
[test4]
|
||||
skip-if = ''' os == 'win' && debug '''
|
||||
|
||||
[test5]
|
||||
foo = "bar"
|
||||
|
||||
[test6]
|
||||
skip-if = ''' debug ''' # a second pesky comment
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
[test1]
|
||||
subsuite = "baz"
|
||||
|
||||
[test2]
|
||||
subsuite = "foo"
|
|
@ -0,0 +1,9 @@
|
|||
[DEFAULT]
|
||||
support-files = "foo.js" # a comment
|
||||
|
||||
[test7]
|
||||
[test8]
|
||||
support-files = "bar.js" # another comment
|
||||
[test9]
|
||||
foo = "bar"
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# illustrate test filters based on various categories
|
||||
|
||||
[windowstest]
|
||||
skip-if = ''' os != 'win' '''
|
||||
|
||||
[fleem]
|
||||
skip-if = ''' os == 'mac' '''
|
||||
|
||||
[linuxtest]
|
||||
skip-if = ''' (os == 'mac') || (os == 'win') '''
|
||||
fail-if = ''' toolkit == 'cocoa' '''
|
|
@ -0,0 +1,11 @@
|
|||
[DEFAULT]
|
||||
foo = "bar"
|
||||
|
||||
["include:include/bar.toml"]
|
||||
|
||||
[fleem]
|
||||
|
||||
["include:include/foo.toml"]
|
||||
red = "roses"
|
||||
blue = "violets"
|
||||
yellow = "daffodils"
|
|
@ -0,0 +1 @@
|
|||
["include:invalid.ini"]
|
|
@ -0,0 +1,4 @@
|
|||
[DEFAULT]
|
||||
foo = "fleem"
|
||||
|
||||
[crash-handling]
|
|
@ -0,0 +1,5 @@
|
|||
[DEFAULT]
|
||||
blue = "ocean"
|
||||
|
||||
[flowers]
|
||||
yellow = "submarine"
|
|
@ -0,0 +1,2 @@
|
|||
[DEFAULT]
|
||||
foo = "bar"
|
|
@ -0,0 +1,13 @@
|
|||
[DEFAULT]
|
||||
subsuite = "mozbase"
|
||||
['test_expressionparser.py']
|
||||
['test_manifestparser.py']
|
||||
['test_testmanifest.py']
|
||||
['test_read_ini.py']
|
||||
['test_convert_directory.py']
|
||||
['test_filters.py']
|
||||
['test_chunking.py']
|
||||
['test_convert_symlinks.py']
|
||||
disabled = 'https://bugzilla.mozilla.org/show_bug.cgi?id=920938'
|
||||
['test_default_overrides.py']
|
||||
['test_util.py']
|
|
@ -0,0 +1,2 @@
|
|||
[foo]
|
||||
[bar]
|
|
@ -0,0 +1,80 @@
|
|||
["testAddons/testDisableEnablePlugin.js"]
|
||||
["testAddons/testGetAddons.js"]
|
||||
["testAddons/testSearchAddons.js"]
|
||||
["testAwesomeBar/testAccessLocationBar.js"]
|
||||
["testAwesomeBar/testCheckItemHighlight.js"]
|
||||
["testAwesomeBar/testEscapeAutocomplete.js"]
|
||||
["testAwesomeBar/testFaviconInAutocomplete.js"]
|
||||
["testAwesomeBar/testGoButton.js"]
|
||||
["testAwesomeBar/testLocationBarSearches.js"]
|
||||
["testAwesomeBar/testPasteLocationBar.js"]
|
||||
["testAwesomeBar/testSuggestHistoryBookmarks.js"]
|
||||
["testAwesomeBar/testVisibleItemsMax.js"]
|
||||
["testBookmarks/testAddBookmarkToMenu.js"]
|
||||
["testCookies/testDisableCookies.js"]
|
||||
["testCookies/testEnableCookies.js"]
|
||||
["testCookies/testRemoveAllCookies.js"]
|
||||
["testCookies/testRemoveCookie.js"]
|
||||
["testDownloading/testCloseDownloadManager.js"]
|
||||
["testDownloading/testDownloadStates.js"]
|
||||
["testDownloading/testOpenDownloadManager.js"]
|
||||
["testFindInPage/testFindInPage.js"]
|
||||
["testFormManager/testAutoCompleteOff.js"]
|
||||
["testFormManager/testBasicFormCompletion.js"]
|
||||
["testFormManager/testClearFormHistory.js"]
|
||||
["testFormManager/testDisableFormManager.js"]
|
||||
["testGeneral/testGoogleSuggestions.js"]
|
||||
["testGeneral/testStopReloadButtons.js"]
|
||||
["testInstallation/testBreakpadInstalled.js"]
|
||||
["testLayout/testNavigateFTP.js"]
|
||||
["testPasswordManager/testPasswordNotSaved.js"]
|
||||
["testPasswordManager/testPasswordSavedAndDeleted.js"]
|
||||
["testPopups/testPopupsAllowed.js"]
|
||||
["testPopups/testPopupsBlocked.js"]
|
||||
["testPreferences/testPaneRetention.js"]
|
||||
["testPreferences/testPreferredLanguage.js"]
|
||||
["testPreferences/testRestoreHomepageToDefault.js"]
|
||||
["testPreferences/testSetToCurrentPage.js"]
|
||||
["testPreferences/testSwitchPanes.js"]
|
||||
["testPrivateBrowsing/testAboutPrivateBrowsing.js"]
|
||||
["testPrivateBrowsing/testCloseWindow.js"]
|
||||
["testPrivateBrowsing/testDisabledElements.js"]
|
||||
["testPrivateBrowsing/testDisabledPermissions.js"]
|
||||
["testPrivateBrowsing/testDownloadManagerClosed.js"]
|
||||
["testPrivateBrowsing/testGeolocation.js"]
|
||||
["testPrivateBrowsing/testStartStopPBMode.js"]
|
||||
["testPrivateBrowsing/testTabRestoration.js"]
|
||||
["testPrivateBrowsing/testTabsDismissedOnStop.js"]
|
||||
["testSearch/testAddMozSearchProvider.js"]
|
||||
["testSearch/testFocusAndSearch.js"]
|
||||
["testSearch/testGetMoreSearchEngines.js"]
|
||||
["testSearch/testOpenSearchAutodiscovery.js"]
|
||||
["testSearch/testRemoveSearchEngine.js"]
|
||||
["testSearch/testReorderSearchEngines.js"]
|
||||
["testSearch/testRestoreDefaults.js"]
|
||||
["testSearch/testSearchSelection.js"]
|
||||
["testSearch/testSearchSuggestions.js"]
|
||||
["testSecurity/testBlueLarry.js"]
|
||||
["testSecurity/testDefaultPhishingEnabled.js"]
|
||||
["testSecurity/testDefaultSecurityPrefs.js"]
|
||||
["testSecurity/testEncryptedPageWarning.js"]
|
||||
["testSecurity/testGreenLarry.js"]
|
||||
["testSecurity/testGreyLarry.js"]
|
||||
["testSecurity/testIdentityPopupOpenClose.js"]
|
||||
["testSecurity/testSSLDisabledErrorPage.js"]
|
||||
["testSecurity/testSafeBrowsingNotificationBar.js"]
|
||||
["testSecurity/testSafeBrowsingWarningPages.js"]
|
||||
["testSecurity/testSecurityInfoViaMoreInformation.js"]
|
||||
["testSecurity/testSecurityNotification.js"]
|
||||
["testSecurity/testSubmitUnencryptedInfoWarning.js"]
|
||||
["testSecurity/testUnknownIssuer.js"]
|
||||
["testSecurity/testUntrustedConnectionErrorPage.js"]
|
||||
["testSessionStore/testUndoTabFromContextMenu.js"]
|
||||
["testTabbedBrowsing/testBackgroundTabScrolling.js"]
|
||||
["testTabbedBrowsing/testCloseTab.js"]
|
||||
["testTabbedBrowsing/testNewTab.js"]
|
||||
["testTabbedBrowsing/testNewWindow.js"]
|
||||
["testTabbedBrowsing/testOpenInBackground.js"]
|
||||
["testTabbedBrowsing/testOpenInForeground.js"]
|
||||
["testTechnicalTools/testAccessPageInfoDialog.js"]
|
||||
["testToolbar/testBackForwardButtons.js"]
|
|
@ -0,0 +1,26 @@
|
|||
[DEFAULT]
|
||||
type = "restart"
|
||||
|
||||
["restartTests/testExtensionInstallUninstall/test2.js"]
|
||||
foo = "bar"
|
||||
|
||||
["restartTests/testExtensionInstallUninstall/test1.js"]
|
||||
foo = "baz"
|
||||
|
||||
["restartTests/testExtensionInstallUninstall/test3.js"]
|
||||
["restartTests/testSoftwareUpdateAutoProxy/test2.js"]
|
||||
["restartTests/testSoftwareUpdateAutoProxy/test1.js"]
|
||||
["restartTests/testPrimaryPassword/test1.js"]
|
||||
["restartTests/testExtensionInstallGetAddons/test2.js"]
|
||||
["restartTests/testExtensionInstallGetAddons/test1.js"]
|
||||
["restartTests/testMultipleExtensionInstallation/test2.js"]
|
||||
["restartTests/testMultipleExtensionInstallation/test1.js"]
|
||||
["restartTests/testThemeInstallUninstall/test2.js"]
|
||||
["restartTests/testThemeInstallUninstall/test1.js"]
|
||||
["restartTests/testThemeInstallUninstall/test3.js"]
|
||||
["restartTests/testDefaultBookmarks/test1.js"]
|
||||
["softwareUpdate/testFallbackUpdate/test2.js"]
|
||||
["softwareUpdate/testFallbackUpdate/test1.js"]
|
||||
["softwareUpdate/testFallbackUpdate/test3.js"]
|
||||
["softwareUpdate/testDirectUpdate/test2.js"]
|
||||
["softwareUpdate/testDirectUpdate/test1.js"]
|
|
@ -0,0 +1,2 @@
|
|||
[DEFAULT]
|
||||
foo = "bar"
|
|
@ -0,0 +1,3 @@
|
|||
["parent:../manifest.ini"]
|
||||
|
||||
['testFirst.js']
|
|
@ -0,0 +1,8 @@
|
|||
[DEFAULT]
|
||||
top = "data"
|
||||
|
||||
["include:first/manifest.ini"]
|
||||
disabled = "YES"
|
||||
|
||||
["include:second/manifest.ini"]
|
||||
disabled = "NO"
|
|
@ -0,0 +1,3 @@
|
|||
["parent:../manifest.ini"]
|
||||
|
||||
['testSecond.js']
|
|
@ -0,0 +1,5 @@
|
|||
[DEFAULT]
|
||||
x = "level_1"
|
||||
|
||||
[test_1]
|
||||
[test_2]
|
|
@ -0,0 +1,3 @@
|
|||
["parent:../level_1.ini"]
|
||||
|
||||
[test_2]
|
|
@ -0,0 +1,3 @@
|
|||
["parent:../level_2.ini"]
|
||||
|
||||
[test_3]
|
|
@ -0,0 +1,6 @@
|
|||
["parent:../level_2.ini"]
|
||||
|
||||
[DEFAULT]
|
||||
x = "level_3"
|
||||
|
||||
[test_3]
|
|
@ -0,0 +1,2 @@
|
|||
[foo]
|
||||
path = "fleem"
|
|
@ -0,0 +1,5 @@
|
|||
[foo]
|
||||
path = "../fleem"
|
||||
|
||||
[bar]
|
||||
path = "../testsSIBLING/example"
|
|
@ -10,4 +10,4 @@ subsuite=baz
|
|||
[test4]
|
||||
[test5]
|
||||
[test6]
|
||||
subsuite=bar,foo=="szy" || foo=="bar"
|
||||
subsuite=bar,foo=="szy" || foo=="bar"
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
[test1]
|
||||
subsuite='bar,foo=="bar"' # this has a comment
|
||||
|
||||
[test2]
|
||||
subsuite='bar,foo=="bar"'
|
||||
|
||||
[test3]
|
||||
subsuite='baz'
|
||||
|
||||
[test4]
|
||||
[test5]
|
||||
[test6]
|
||||
subsuite='bar,foo=="szy" || foo=="bar"'
|
|
@ -8,8 +8,6 @@ from unittest import TestCase
|
|||
|
||||
import mozunit
|
||||
from manifestparser.filters import chunk_by_dir, chunk_by_runtime, chunk_by_slice
|
||||
from six import iteritems
|
||||
from six.moves import range
|
||||
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
@ -84,7 +82,7 @@ class ChunkByDir(TestCase):
|
|||
{ <dir>: <num tests> }
|
||||
"""
|
||||
i = 0
|
||||
for d, num in iteritems(dirs):
|
||||
for d, num in dirs.items():
|
||||
for _ in range(num):
|
||||
i += 1
|
||||
name = "test%i" % i
|
||||
|
@ -99,8 +97,8 @@ class ChunkByDir(TestCase):
|
|||
|
||||
def num_groups(tests):
|
||||
unique = set()
|
||||
for p in [t["relpath"] for t in tests]:
|
||||
p = p.split(os.sep)
|
||||
for rp in [t["relpath"] for t in tests]:
|
||||
p = rp.split(os.sep)
|
||||
p = p[: min(depth, len(p) - 1)]
|
||||
unique.add(os.sep.join(p))
|
||||
return len(unique)
|
||||
|
@ -174,7 +172,7 @@ class ChunkByRuntime(TestCase):
|
|||
{ <dir>: <num tests> }
|
||||
"""
|
||||
i = 0
|
||||
for d, num in iteritems(dirs):
|
||||
for d, num in dirs.items():
|
||||
for _ in range(num):
|
||||
i += 1
|
||||
name = "test%i" % i
|
||||
|
@ -195,7 +193,7 @@ class ChunkByRuntime(TestCase):
|
|||
|
||||
def chunk_by_round_robin(self, tests, total, runtimes):
|
||||
tests_by_manifest = []
|
||||
for manifest, runtime in iteritems(runtimes):
|
||||
for manifest, runtime in runtimes.items():
|
||||
mtests = [t for t in tests if t["manifest_relpath"] == manifest]
|
||||
tests_by_manifest.append((runtime, mtests))
|
||||
tests_by_manifest.sort(key=lambda x: x[0], reverse=False)
|
||||
|
|
|
@ -100,6 +100,32 @@ class TestDirectoryConversion(unittest.TestCase):
|
|||
finally:
|
||||
shutil.rmtree(stub)
|
||||
|
||||
def test_convert_directory_manifests_in_place_toml(self):
|
||||
"""
|
||||
keep the manifests in place (TOML)
|
||||
"""
|
||||
|
||||
stub = self.create_stub()
|
||||
try:
|
||||
ManifestParser.populate_directory_manifests([stub], filename="manifest.ini")
|
||||
self.assertEqual(
|
||||
sorted(os.listdir(stub)),
|
||||
["bar", "fleem", "foo", "manifest.ini", "subdir"],
|
||||
)
|
||||
parser = ManifestParser(use_toml=True)
|
||||
parser.read(os.path.join(stub, "manifest.ini"))
|
||||
self.assertEqual(
|
||||
[i["name"] for i in parser.tests], ["subfile", "bar", "fleem", "foo"]
|
||||
)
|
||||
parser = ManifestParser(use_toml=True)
|
||||
parser.read(os.path.join(stub, "subdir", "manifest.ini"))
|
||||
self.assertEqual(len(parser.tests), 1)
|
||||
self.assertEqual(parser.tests[0]["name"], "subfile")
|
||||
except BaseException:
|
||||
raise
|
||||
finally:
|
||||
shutil.rmtree(stub)
|
||||
|
||||
def test_manifest_ignore(self):
|
||||
"""test manifest `ignore` parameter for ignoring directories"""
|
||||
|
||||
|
@ -119,6 +145,25 @@ class TestDirectoryConversion(unittest.TestCase):
|
|||
finally:
|
||||
shutil.rmtree(stub)
|
||||
|
||||
def test_manifest_ignore_toml(self):
|
||||
"""test manifest `ignore` parameter for ignoring directories (TOML)"""
|
||||
|
||||
stub = self.create_stub()
|
||||
try:
|
||||
ManifestParser.populate_directory_manifests(
|
||||
[stub], filename="manifest.ini", ignore=("subdir",)
|
||||
)
|
||||
parser = ManifestParser(use_toml=True)
|
||||
parser.read(os.path.join(stub, "manifest.ini"))
|
||||
self.assertEqual([i["name"] for i in parser.tests], ["bar", "fleem", "foo"])
|
||||
self.assertFalse(
|
||||
os.path.exists(os.path.join(stub, "subdir", "manifest.ini"))
|
||||
)
|
||||
except BaseException:
|
||||
raise
|
||||
finally:
|
||||
shutil.rmtree(stub)
|
||||
|
||||
def test_pattern(self):
|
||||
"""test directory -> manifest with a file pattern"""
|
||||
|
||||
|
@ -182,6 +227,51 @@ class TestDirectoryConversion(unittest.TestCase):
|
|||
shutil.rmtree(tempdir)
|
||||
shutil.rmtree(newtempdir)
|
||||
|
||||
def test_update_toml(self):
|
||||
"""
|
||||
Test our ability to update tests from a manifest and a directory of
|
||||
files (TOML)
|
||||
"""
|
||||
|
||||
# boilerplate
|
||||
tempdir = create_realpath_tempdir()
|
||||
for i in range(10):
|
||||
open(os.path.join(tempdir, str(i)), "w").write(str(i))
|
||||
|
||||
# otherwise empty directory with a manifest file
|
||||
newtempdir = create_realpath_tempdir()
|
||||
manifest_file = os.path.join(newtempdir, "manifest.ini")
|
||||
manifest_contents = str(convert([tempdir], relative_to=tempdir))
|
||||
with open(manifest_file, "w") as f:
|
||||
f.write(manifest_contents)
|
||||
|
||||
# get the manifest
|
||||
manifest = ManifestParser(manifests=(manifest_file,), use_toml=True)
|
||||
|
||||
# All of the tests are initially missing:
|
||||
paths = [str(i) for i in range(10)]
|
||||
self.assertEqual([i["name"] for i in manifest.missing()], paths)
|
||||
|
||||
# But then we copy one over:
|
||||
self.assertEqual(manifest.get("name", name="1"), ["1"])
|
||||
manifest.update(tempdir, name="1")
|
||||
self.assertEqual(sorted(os.listdir(newtempdir)), ["1", "manifest.ini"])
|
||||
|
||||
# Update that one file and copy all the "tests":
|
||||
open(os.path.join(tempdir, "1"), "w").write("secret door")
|
||||
manifest.update(tempdir)
|
||||
self.assertEqual(
|
||||
sorted(os.listdir(newtempdir)),
|
||||
["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "manifest.ini"],
|
||||
)
|
||||
self.assertEqual(
|
||||
open(os.path.join(newtempdir, "1")).read().strip(), "secret door"
|
||||
)
|
||||
|
||||
# clean up:
|
||||
shutil.rmtree(tempdir)
|
||||
shutil.rmtree(newtempdir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mozunit.main()
|
||||
|
|
|
@ -36,6 +36,35 @@ class TestDefaultSkipif(unittest.TestCase):
|
|||
elif test["name"] == "test6":
|
||||
self.assertEqual(test["skip-if"], "os == 'win' && debug\ndebug")
|
||||
|
||||
def test_defaults_toml(self):
|
||||
|
||||
default = os.path.join(here, "default-skipif.ini")
|
||||
parser = ManifestParser(manifests=(default,), use_toml=True)
|
||||
for test in parser.tests:
|
||||
if test["name"] == "test1":
|
||||
self.assertEqual(
|
||||
test["skip-if"].strip(), "os == 'win' && debug \n debug"
|
||||
)
|
||||
elif test["name"] == "test2":
|
||||
self.assertEqual(
|
||||
test["skip-if"].strip(), "os == 'win' && debug \n os == 'linux'"
|
||||
)
|
||||
elif test["name"] == "test3":
|
||||
self.assertEqual(
|
||||
test["skip-if"].strip(), "os == 'win' && debug \n os == 'win'"
|
||||
)
|
||||
elif test["name"] == "test4":
|
||||
self.assertEqual(
|
||||
test["skip-if"].strip(),
|
||||
"os == 'win' && debug \n os == 'win' && debug",
|
||||
)
|
||||
elif test["name"] == "test5":
|
||||
self.assertEqual(test["skip-if"].strip(), "os == 'win' && debug")
|
||||
elif test["name"] == "test6":
|
||||
self.assertEqual(
|
||||
test["skip-if"].strip(), "os == 'win' && debug \n debug"
|
||||
)
|
||||
|
||||
|
||||
class TestDefaultSupportFiles(unittest.TestCase):
|
||||
"""Tests combining support-files field in [DEFAULT] with the value for a test"""
|
||||
|
@ -53,6 +82,19 @@ class TestDefaultSupportFiles(unittest.TestCase):
|
|||
expected = expected_supp_files[test["name"]]
|
||||
self.assertEqual(test["support-files"], expected)
|
||||
|
||||
def test_defaults_toml(self):
|
||||
|
||||
default = os.path.join(here, "default-suppfiles.ini")
|
||||
parser = ManifestParser(manifests=(default,), use_toml=True)
|
||||
expected_supp_files = {
|
||||
"test7": "foo.js",
|
||||
"test8": "foo.js bar.js",
|
||||
"test9": "foo.js",
|
||||
}
|
||||
for test in parser.tests:
|
||||
expected = expected_supp_files[test["name"]]
|
||||
self.assertEqual(test["support-files"], expected)
|
||||
|
||||
|
||||
class TestOmitDefaults(unittest.TestCase):
|
||||
"""Tests passing omit-defaults prevents defaults from propagating to definitions."""
|
||||
|
@ -99,6 +141,50 @@ class TestOmitDefaults(unittest.TestCase):
|
|||
self.assertIn(key, actual_defaults)
|
||||
self.assertEqual(value, actual_defaults[key])
|
||||
|
||||
def test_defaults_toml(self):
|
||||
manifests = (
|
||||
os.path.join(here, "default-suppfiles.toml"),
|
||||
os.path.join(here, "default-skipif.toml"),
|
||||
)
|
||||
parser = ManifestParser(
|
||||
manifests=manifests, handle_defaults=False, use_toml=True
|
||||
)
|
||||
expected_supp_files = {
|
||||
"test8": "bar.js",
|
||||
}
|
||||
expected_skip_ifs = {
|
||||
"test1": "debug",
|
||||
"test2": "os == 'linux'",
|
||||
"test3": "os == 'win'",
|
||||
"test4": "os == 'win' && debug",
|
||||
"test6": "debug",
|
||||
}
|
||||
for test in parser.tests:
|
||||
for field, expectations in (
|
||||
("support-files", expected_supp_files),
|
||||
("skip-if", expected_skip_ifs),
|
||||
):
|
||||
expected = expectations.get(test["name"])
|
||||
if not expected:
|
||||
self.assertNotIn(field, test)
|
||||
else:
|
||||
self.assertEqual(test[field].strip(), expected)
|
||||
|
||||
expected_defaults = {
|
||||
os.path.join(here, "default-suppfiles.toml"): {
|
||||
"support-files": "foo.js",
|
||||
},
|
||||
os.path.join(here, "default-skipif.toml"): {
|
||||
"skip-if": "os == 'win' && debug",
|
||||
},
|
||||
}
|
||||
for path, defaults in expected_defaults.items():
|
||||
self.assertIn(path, parser.manifest_defaults)
|
||||
actual_defaults = parser.manifest_defaults[path]
|
||||
for key, value in defaults.items():
|
||||
self.assertIn(key, actual_defaults)
|
||||
self.assertEqual(value, actual_defaults[key].strip())
|
||||
|
||||
|
||||
class TestSubsuiteDefaults(unittest.TestCase):
|
||||
"""Test that subsuites are handled correctly when managing defaults
|
||||
|
@ -116,6 +202,20 @@ class TestSubsuiteDefaults(unittest.TestCase):
|
|||
value = combine_fields(defaults, test)
|
||||
self.assertEqual(expected_subsuites[value["name"]], value["subsuite"])
|
||||
|
||||
def test_subsuite_defaults_toml(self):
|
||||
manifest = os.path.join(here, "default-subsuite.toml")
|
||||
parser = ManifestParser(
|
||||
manifests=(manifest,), handle_defaults=False, use_toml=True
|
||||
)
|
||||
expected_subsuites = {
|
||||
"test1": "baz",
|
||||
"test2": "foo",
|
||||
}
|
||||
defaults = parser.manifest_defaults[manifest]
|
||||
for test in parser.tests:
|
||||
value = combine_fields(defaults, test)
|
||||
self.assertEqual(expected_subsuites[value["name"]], value["subsuite"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mozunit.main()
|
||||
|
|
|
@ -122,7 +122,8 @@ def test_filters_run_in_order():
|
|||
def create_tests():
|
||||
def inner(*paths, **defaults):
|
||||
tests = []
|
||||
for path in paths:
|
||||
for p in paths:
|
||||
path = p
|
||||
if isinstance(path, tuple):
|
||||
path, kwargs = path
|
||||
else:
|
||||
|
|
|
@ -8,10 +8,10 @@ import os
|
|||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from io import StringIO
|
||||
|
||||
import mozunit
|
||||
from manifestparser import ManifestParser
|
||||
from six import StringIO
|
||||
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
@ -66,6 +66,49 @@ class TestManifestParser(unittest.TestCase):
|
|||
["restartTests/testExtensionInstallUninstall/test2.js"],
|
||||
)
|
||||
|
||||
def test_sanity_toml(self):
|
||||
"""Ensure basic parser is sane (TOML)"""
|
||||
|
||||
parser = ManifestParser(use_toml=True)
|
||||
mozmill_example = os.path.join(here, "mozmill-example.toml")
|
||||
parser.read(mozmill_example)
|
||||
tests = parser.tests
|
||||
self.assertEqual(
|
||||
len(tests), len(open(mozmill_example).read().strip().splitlines())
|
||||
)
|
||||
|
||||
# Ensure that capitalization and order aren't an issue:
|
||||
lines = ['["%s"]' % test["name"] for test in tests]
|
||||
self.assertEqual(lines, open(mozmill_example).read().strip().splitlines())
|
||||
|
||||
# Show how you select subsets of tests:
|
||||
mozmill_restart_example = os.path.join(here, "mozmill-restart-example.toml")
|
||||
parser.read(mozmill_restart_example)
|
||||
restart_tests = parser.get(type="restart")
|
||||
self.assertTrue(len(restart_tests) < len(parser.tests))
|
||||
self.assertEqual(
|
||||
len(restart_tests), len(parser.get(manifest=mozmill_restart_example))
|
||||
)
|
||||
self.assertFalse(
|
||||
[
|
||||
test
|
||||
for test in restart_tests
|
||||
if test["manifest"]
|
||||
!= os.path.join(here, "mozmill-restart-example.toml")
|
||||
]
|
||||
)
|
||||
self.assertEqual(
|
||||
parser.get("name", tags=["foo"]),
|
||||
[
|
||||
"restartTests/testExtensionInstallUninstall/test2.js",
|
||||
"restartTests/testExtensionInstallUninstall/test1.js",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
parser.get("name", foo="bar"),
|
||||
["restartTests/testExtensionInstallUninstall/test2.js"],
|
||||
)
|
||||
|
||||
def test_include(self):
|
||||
"""Illustrate how include works"""
|
||||
|
||||
|
@ -139,6 +182,86 @@ foo = bar
|
|||
|
||||
[fleem]
|
||||
|
||||
[include/flowers]
|
||||
blue = ocean
|
||||
red = roses
|
||||
yellow = submarine""" # noqa
|
||||
|
||||
self.assertEqual(buffer.getvalue().strip(), expected_output)
|
||||
|
||||
def test_include_toml(self):
|
||||
"""Illustrate how include works (TOML)"""
|
||||
|
||||
include_example = os.path.join(here, "include-example.toml")
|
||||
parser = ManifestParser(manifests=(include_example,), use_toml=True)
|
||||
|
||||
# All of the tests should be included, in order:
|
||||
self.assertEqual(parser.get("name"), ["crash-handling", "fleem", "flowers"])
|
||||
self.assertEqual(
|
||||
[
|
||||
(test["name"], os.path.basename(test["manifest"]))
|
||||
for test in parser.tests
|
||||
],
|
||||
[
|
||||
("crash-handling", "bar.toml"),
|
||||
("fleem", "include-example.toml"),
|
||||
("flowers", "foo.toml"),
|
||||
],
|
||||
)
|
||||
|
||||
# The including manifest is always reported as a part of the generated test object.
|
||||
self.assertTrue(
|
||||
all(
|
||||
[
|
||||
t["ancestor_manifest"] == "include-example.toml"
|
||||
for t in parser.tests
|
||||
if t["name"] != "fleem"
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
# The manifests should be there too:
|
||||
self.assertEqual(len(parser.manifests()), 3)
|
||||
|
||||
# We already have the root directory:
|
||||
self.assertEqual(here, parser.rootdir)
|
||||
|
||||
# DEFAULT values should persist across includes, unless they're
|
||||
# overwritten. In this example, include-example.toml sets foo=bar, but
|
||||
# it's overridden to fleem in bar.toml
|
||||
self.assertEqual(parser.get("name", foo="bar"), ["fleem", "flowers"])
|
||||
self.assertEqual(parser.get("name", foo="fleem"), ["crash-handling"])
|
||||
|
||||
# Passing parameters in the include section allows defining variables in
|
||||
# the submodule scope:
|
||||
self.assertEqual(parser.get("name", tags=["red"]), ["flowers"])
|
||||
|
||||
# However, this should be overridable from the DEFAULT section in the
|
||||
# included file and that overridable via the key directly connected to
|
||||
# the test:
|
||||
self.assertEqual(parser.get(name="flowers")[0]["blue"], "ocean")
|
||||
self.assertEqual(parser.get(name="flowers")[0]["yellow"], "submarine")
|
||||
|
||||
# You can query multiple times if you need to:
|
||||
flowers = parser.get(foo="bar")
|
||||
self.assertEqual(len(flowers), 2)
|
||||
|
||||
# Using the inverse flag should invert the set of tests returned:
|
||||
self.assertEqual(
|
||||
parser.get("name", inverse=True, tags=["red"]), ["crash-handling", "fleem"]
|
||||
)
|
||||
|
||||
# All of the included tests actually exist:
|
||||
self.assertEqual([i["name"] for i in parser.missing()], [])
|
||||
|
||||
# Write the output to a manifest:
|
||||
buffer = StringIO()
|
||||
parser.write(fp=buffer, global_kwargs={"foo": "bar"})
|
||||
expected_output = """[DEFAULT]
|
||||
foo = bar
|
||||
|
||||
[fleem]
|
||||
|
||||
[include/flowers]
|
||||
blue = ocean
|
||||
red = roses
|
||||
|
@ -181,6 +304,41 @@ yellow = submarine""" # noqa
|
|||
self.assertIn(include_example, manifests)
|
||||
self.assertIn(noinclude_example, manifests)
|
||||
|
||||
def test_include_manifest_defaults_toml(self):
|
||||
"""
|
||||
Test that manifest_defaults and manifests() are correctly populated
|
||||
when includes are used. (TOML)
|
||||
"""
|
||||
|
||||
include_example = os.path.join(here, "include-example.toml")
|
||||
noinclude_example = os.path.join(here, "just-defaults.toml")
|
||||
bar_path = os.path.join(here, "include", "bar.toml")
|
||||
foo_path = os.path.join(here, "include", "foo.toml")
|
||||
|
||||
parser = ManifestParser(
|
||||
manifests=(include_example, noinclude_example), rootdir=here, use_toml=True
|
||||
)
|
||||
|
||||
# Standalone manifests must be appear as-is.
|
||||
self.assertTrue(include_example in parser.manifest_defaults)
|
||||
self.assertTrue(noinclude_example in parser.manifest_defaults)
|
||||
|
||||
# Included manifests must only appear together with the parent manifest
|
||||
# that included the manifest.
|
||||
self.assertFalse(bar_path in parser.manifest_defaults)
|
||||
self.assertFalse(foo_path in parser.manifest_defaults)
|
||||
ancestor_ini = os.path.relpath(include_example, parser.rootdir)
|
||||
self.assertTrue((ancestor_ini, bar_path) in parser.manifest_defaults)
|
||||
self.assertTrue((ancestor_ini, foo_path) in parser.manifest_defaults)
|
||||
|
||||
# manifests() must only return file paths (strings).
|
||||
manifests = parser.manifests()
|
||||
self.assertEqual(len(manifests), 4)
|
||||
self.assertIn(foo_path, manifests)
|
||||
self.assertIn(bar_path, manifests)
|
||||
self.assertIn(include_example, manifests)
|
||||
self.assertIn(noinclude_example, manifests)
|
||||
|
||||
def test_include_handle_defaults_False(self):
|
||||
"""
|
||||
Test that manifest_defaults and manifests() are correct even when
|
||||
|
@ -214,6 +372,39 @@ yellow = submarine""" # noqa
|
|||
},
|
||||
)
|
||||
|
||||
def test_include_handle_defaults_False_toml(self):
|
||||
"""
|
||||
Test that manifest_defaults and manifests() are correct even when
|
||||
handle_defaults is set to False. (TOML)
|
||||
"""
|
||||
manifest = os.path.join(here, "include-example.toml")
|
||||
foo_path = os.path.join(here, "include", "foo.toml")
|
||||
|
||||
parser = ManifestParser(
|
||||
manifests=(manifest,), handle_defaults=False, rootdir=here, use_toml=True
|
||||
)
|
||||
ancestor_ini = os.path.relpath(manifest, parser.rootdir)
|
||||
|
||||
self.assertIn(manifest, parser.manifest_defaults)
|
||||
self.assertNotIn(foo_path, parser.manifest_defaults)
|
||||
self.assertIn((ancestor_ini, foo_path), parser.manifest_defaults)
|
||||
self.assertEqual(
|
||||
parser.manifest_defaults[manifest],
|
||||
{
|
||||
"foo": "bar",
|
||||
"here": here,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
parser.manifest_defaults[(ancestor_ini, foo_path)],
|
||||
{
|
||||
"here": os.path.join(here, "include"),
|
||||
"red": "roses",
|
||||
"blue": "ocean",
|
||||
"yellow": "daffodils",
|
||||
},
|
||||
)
|
||||
|
||||
def test_include_repeated(self):
|
||||
"""
|
||||
Test that repeatedly included manifests are independent of each other.
|
||||
|
@ -291,16 +482,105 @@ yellow = submarine
|
|||
"%s%s" % (included_output, include_output),
|
||||
)
|
||||
|
||||
def test_include_repeated_toml(self):
|
||||
"""
|
||||
Test that repeatedly included manifests are independent of each other. (TOML)
|
||||
"""
|
||||
include_example = os.path.join(here, "include-example.toml")
|
||||
included_foo = os.path.join(here, "include", "foo.toml")
|
||||
|
||||
# In the expected output, blue and yellow have the values from foo.toml
|
||||
# (ocean, submarine) instead of the ones from include-example.toml
|
||||
# (violets, daffodils), because the defaults in the included file take
|
||||
# precedence over the values from the parent.
|
||||
include_output = """[include/crash-handling]
|
||||
foo = fleem
|
||||
|
||||
[fleem]
|
||||
foo = bar
|
||||
|
||||
[include/flowers]
|
||||
blue = ocean
|
||||
foo = bar
|
||||
red = roses
|
||||
yellow = submarine
|
||||
|
||||
"""
|
||||
included_output = """[include/flowers]
|
||||
blue = ocean
|
||||
yellow = submarine
|
||||
|
||||
"""
|
||||
|
||||
parser = ManifestParser(
|
||||
manifests=(include_example, included_foo), rootdir=here, use_toml=True
|
||||
)
|
||||
self.assertEqual(
|
||||
parser.get("name"), ["crash-handling", "fleem", "flowers", "flowers"]
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
(test["name"], os.path.basename(test["manifest"]))
|
||||
for test in parser.tests
|
||||
],
|
||||
[
|
||||
("crash-handling", "bar.toml"),
|
||||
("fleem", "include-example.toml"),
|
||||
("flowers", "foo.toml"),
|
||||
("flowers", "foo.toml"),
|
||||
],
|
||||
)
|
||||
self.check_included_repeat(
|
||||
parser,
|
||||
parser.tests[3],
|
||||
parser.tests[2],
|
||||
"%s%s" % (include_output, included_output),
|
||||
True,
|
||||
)
|
||||
|
||||
# Same tests, but with the load order of the manifests swapped.
|
||||
parser = ManifestParser(
|
||||
manifests=(included_foo, include_example), rootdir=here, use_toml=True
|
||||
)
|
||||
self.assertEqual(
|
||||
parser.get("name"), ["flowers", "crash-handling", "fleem", "flowers"]
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
(test["name"], os.path.basename(test["manifest"]))
|
||||
for test in parser.tests
|
||||
],
|
||||
[
|
||||
("flowers", "foo.toml"),
|
||||
("crash-handling", "bar.toml"),
|
||||
("fleem", "include-example.toml"),
|
||||
("flowers", "foo.toml"),
|
||||
],
|
||||
)
|
||||
self.check_included_repeat(
|
||||
parser,
|
||||
parser.tests[0],
|
||||
parser.tests[3],
|
||||
"%s%s" % (included_output, include_output),
|
||||
True,
|
||||
)
|
||||
|
||||
def check_included_repeat(
|
||||
self, parser, isolated_test, included_test, expected_output
|
||||
self, parser, isolated_test, included_test, expected_output, use_toml=False
|
||||
):
|
||||
include_example = os.path.join(here, "include-example.ini")
|
||||
included_foo = os.path.join(here, "include", "foo.ini")
|
||||
if use_toml:
|
||||
include_example_filename = "include-example.toml"
|
||||
foo_filename = "foo.toml"
|
||||
else:
|
||||
include_example_filename = "include-example.ini"
|
||||
foo_filename = "foo.ini"
|
||||
include_example = os.path.join(here, include_example_filename)
|
||||
included_foo = os.path.join(here, "include", foo_filename)
|
||||
ancestor_ini = os.path.relpath(include_example, parser.rootdir)
|
||||
manifest_default_key = (ancestor_ini, included_foo)
|
||||
|
||||
self.assertFalse("ancestor_manifest" in isolated_test)
|
||||
self.assertEqual(included_test["ancestor_manifest"], "include-example.ini")
|
||||
self.assertEqual(included_test["ancestor_manifest"], include_example_filename)
|
||||
|
||||
self.assertTrue(include_example in parser.manifest_defaults)
|
||||
self.assertTrue(included_foo in parser.manifest_defaults)
|
||||
|
@ -327,6 +607,13 @@ yellow = submarine
|
|||
manifest = os.path.join(here, "include-invalid.ini")
|
||||
ManifestParser(manifests=(manifest,), strict=False)
|
||||
|
||||
def test_invalid_path_toml(self):
|
||||
"""
|
||||
Test invalid path should not throw when not strict (TOML)
|
||||
"""
|
||||
manifest = os.path.join(here, "include-invalid.ini")
|
||||
ManifestParser(manifests=(manifest,), strict=False, use_toml=True)
|
||||
|
||||
def test_copy(self):
|
||||
"""Test our ability to copy a set of manifests"""
|
||||
|
||||
|
@ -347,6 +634,26 @@ yellow = submarine
|
|||
self.assertEqual(to_manifest.get("name"), from_manifest.get("name"))
|
||||
shutil.rmtree(tempdir)
|
||||
|
||||
def test_copy_toml(self):
|
||||
"""Test our ability to copy a set of manifests (TOML)"""
|
||||
|
||||
tempdir = tempfile.mkdtemp()
|
||||
include_example = os.path.join(here, "include-example.toml")
|
||||
manifest = ManifestParser(manifests=(include_example,), use_toml=True)
|
||||
manifest.copy(tempdir)
|
||||
self.assertEqual(
|
||||
sorted(os.listdir(tempdir)), ["fleem", "include", "include-example.toml"]
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(os.listdir(os.path.join(tempdir, "include"))),
|
||||
["bar.toml", "crash-handling", "flowers", "foo.toml"],
|
||||
)
|
||||
from_manifest = ManifestParser(manifests=(include_example,), use_toml=True)
|
||||
to_manifest = os.path.join(tempdir, "include-example.toml")
|
||||
to_manifest = ManifestParser(manifests=(to_manifest,), use_toml=True)
|
||||
self.assertEqual(to_manifest.get("name"), from_manifest.get("name"))
|
||||
shutil.rmtree(tempdir)
|
||||
|
||||
def test_path_override(self):
|
||||
"""You can override the path in the section too.
|
||||
This shows that you can use a relative path"""
|
||||
|
@ -354,11 +661,18 @@ yellow = submarine
|
|||
manifest = ManifestParser(manifests=(path_example,))
|
||||
self.assertEqual(manifest.tests[0]["path"], os.path.join(here, "fleem"))
|
||||
|
||||
def test_path_override_toml(self):
|
||||
"""You can override the path in the section too.
|
||||
This shows that you can use a relative path"""
|
||||
path_example = os.path.join(here, "path-example.toml")
|
||||
manifest = ManifestParser(manifests=(path_example,), use_toml=True)
|
||||
self.assertEqual(manifest.tests[0]["path"], os.path.join(here, "fleem"))
|
||||
|
||||
def test_relative_path(self):
|
||||
"""
|
||||
Relative test paths are correctly calculated.
|
||||
"""
|
||||
relative_path = os.path.join(here, "relative-path.ini")
|
||||
relative_path = os.path.join(here, "relative-path.toml")
|
||||
manifest = ManifestParser(manifests=(relative_path,))
|
||||
self.assertEqual(
|
||||
manifest.tests[0]["path"], os.path.join(os.path.dirname(here), "fleem")
|
||||
|
@ -368,6 +682,20 @@ yellow = submarine
|
|||
manifest.tests[1]["relpath"], os.path.join("..", "testsSIBLING", "example")
|
||||
)
|
||||
|
||||
def test_relative_path_toml(self):
|
||||
"""
|
||||
Relative test paths are correctly calculated. (TOML)
|
||||
"""
|
||||
relative_path = os.path.join(here, "relative-path.toml")
|
||||
manifest = ManifestParser(manifests=(relative_path,), use_toml=True)
|
||||
self.assertEqual(
|
||||
manifest.tests[0]["path"], os.path.join(os.path.dirname(here), "fleem")
|
||||
)
|
||||
self.assertEqual(manifest.tests[0]["relpath"], os.path.join("..", "fleem"))
|
||||
self.assertEqual(
|
||||
manifest.tests[1]["relpath"], os.path.join("..", "testsSIBLING", "example")
|
||||
)
|
||||
|
||||
def test_path_from_fd(self):
|
||||
"""
|
||||
Test paths are left untouched when manifest is a file-like object.
|
||||
|
@ -389,6 +717,18 @@ yellow = submarine
|
|||
names = [i["name"] for i in manifest.tests]
|
||||
self.assertFalse("test_0202_app_launch_apply_update_dirlocked.js" in names)
|
||||
|
||||
def test_comments_toml(self):
|
||||
"""
|
||||
ensure comments work, see
|
||||
https://bugzilla.mozilla.org/show_bug.cgi?id=813674
|
||||
(TOML)
|
||||
"""
|
||||
comment_example = os.path.join(here, "comment-example.toml")
|
||||
manifest = ManifestParser(manifests=(comment_example,), use_toml=True)
|
||||
self.assertEqual(len(manifest.tests), 8)
|
||||
names = [i["name"] for i in manifest.tests]
|
||||
self.assertFalse("test_0202_app_launch_apply_update_dirlocked.js" in names)
|
||||
|
||||
def test_verifyDirectory(self):
|
||||
|
||||
directory = os.path.join(here, "verifyDirectory")
|
||||
|
@ -413,6 +753,30 @@ yellow = submarine
|
|||
missing = manifest.verifyDirectory(directory, extensions=(".js",))
|
||||
self.assertEqual(missing, (set([missing_test]), set()))
|
||||
|
||||
def test_verifyDirectory_toml(self):
|
||||
|
||||
directory = os.path.join(here, "verifyDirectory")
|
||||
|
||||
# correct manifest
|
||||
manifest_path = os.path.join(directory, "verifyDirectory.toml")
|
||||
manifest = ManifestParser(manifests=(manifest_path,), use_toml=True)
|
||||
missing = manifest.verifyDirectory(directory, extensions=(".js",))
|
||||
self.assertEqual(missing, (set(), set()))
|
||||
|
||||
# manifest is missing test_1.js
|
||||
test_1 = os.path.join(directory, "test_1.js")
|
||||
manifest_path = os.path.join(directory, "verifyDirectory_incomplete.toml")
|
||||
manifest = ManifestParser(manifests=(manifest_path,), use_toml=True)
|
||||
missing = manifest.verifyDirectory(directory, extensions=(".js",))
|
||||
self.assertEqual(missing, (set(), set([test_1])))
|
||||
|
||||
# filesystem is missing test_notappearinginthisfilm.js
|
||||
missing_test = os.path.join(directory, "test_notappearinginthisfilm.js")
|
||||
manifest_path = os.path.join(directory, "verifyDirectory_toocomplete.ini")
|
||||
manifest = ManifestParser(manifests=(manifest_path,), use_toml=True)
|
||||
missing = manifest.verifyDirectory(directory, extensions=(".js",))
|
||||
self.assertEqual(missing, (set([missing_test]), set()))
|
||||
|
||||
def test_just_defaults(self):
|
||||
"""Ensure a manifest with just a DEFAULT section exposes that data."""
|
||||
|
||||
|
@ -423,6 +787,16 @@ yellow = submarine
|
|||
self.assertTrue(manifest in parser.manifest_defaults)
|
||||
self.assertEqual(parser.manifest_defaults[manifest]["foo"], "bar")
|
||||
|
||||
def test_just_defaults_toml(self):
|
||||
"""Ensure a manifest with just a DEFAULT section exposes that data. (TOML)"""
|
||||
|
||||
parser = ManifestParser(use_toml=True)
|
||||
manifest = os.path.join(here, "just-defaults.toml")
|
||||
parser.read(manifest)
|
||||
self.assertEqual(len(parser.tests), 0)
|
||||
self.assertTrue(manifest in parser.manifest_defaults)
|
||||
self.assertEqual(parser.manifest_defaults[manifest]["foo"], "bar")
|
||||
|
||||
def test_manifest_list(self):
|
||||
"""
|
||||
Ensure a manifest with just a DEFAULT section still returns
|
||||
|
@ -435,6 +809,18 @@ yellow = submarine
|
|||
self.assertEqual(len(parser.tests), 0)
|
||||
self.assertTrue(len(parser.manifests()) == 1)
|
||||
|
||||
def test_manifest_list_toml(self):
|
||||
"""
|
||||
Ensure a manifest with just a DEFAULT section still returns
|
||||
itself from the manifests() method. (TOML)
|
||||
"""
|
||||
|
||||
parser = ManifestParser(use_toml=True)
|
||||
manifest = os.path.join(here, "no-tests.toml")
|
||||
parser.read(manifest)
|
||||
self.assertEqual(len(parser.tests), 0)
|
||||
self.assertTrue(len(parser.manifests()) == 1)
|
||||
|
||||
def test_manifest_with_invalid_condition(self):
|
||||
"""
|
||||
Ensure a skip-if or similar condition with an assignment in it
|
||||
|
@ -448,6 +834,19 @@ yellow = submarine
|
|||
):
|
||||
parser.read(manifest)
|
||||
|
||||
def test_manifest_with_invalid_condition_toml(self):
|
||||
"""
|
||||
Ensure a skip-if or similar condition with an assignment in it
|
||||
causes errors. (TOML)
|
||||
"""
|
||||
|
||||
parser = ManifestParser(use_toml=True)
|
||||
manifest = os.path.join(here, "broken-skip-if.toml")
|
||||
with self.assertRaisesRegex(
|
||||
Exception, "Should not assign in skip-if condition for DEFAULT"
|
||||
):
|
||||
parser.read(manifest)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mozunit.main()
|
||||
|
|
|
@ -10,12 +10,12 @@ is the default:
|
|||
http://docs.python.org/2/library/configparser.html
|
||||
"""
|
||||
|
||||
from io import StringIO
|
||||
from textwrap import dedent
|
||||
|
||||
import mozunit
|
||||
import pytest
|
||||
from manifestparser import read_ini
|
||||
from six import StringIO
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
|
|
|
@ -44,6 +44,37 @@ class TestTestManifest(unittest.TestCase):
|
|||
last = manifest.active_tests(exists=False, toolkit="cocoa")[-1]
|
||||
self.assertEqual(last["expected"], "fail")
|
||||
|
||||
def test_testmanifest_toml(self):
|
||||
# Test filtering based on platform:
|
||||
filter_example = os.path.join(here, "filter-example.toml")
|
||||
manifest = TestManifest(
|
||||
manifests=(filter_example,), strict=False, use_toml=True
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
i["name"]
|
||||
for i in manifest.active_tests(os="win", disabled=False, exists=False)
|
||||
],
|
||||
["windowstest", "fleem"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
i["name"]
|
||||
for i in manifest.active_tests(os="linux", disabled=False, exists=False)
|
||||
],
|
||||
["fleem", "linuxtest"],
|
||||
)
|
||||
|
||||
# Look for existing tests. There is only one:
|
||||
self.assertEqual([i["name"] for i in manifest.active_tests()], ["fleem"])
|
||||
|
||||
# You should be able to expect failures:
|
||||
last = manifest.active_tests(exists=False, toolkit="gtk")[-1]
|
||||
self.assertEqual(last["name"], "linuxtest")
|
||||
self.assertEqual(last["expected"], "pass")
|
||||
last = manifest.active_tests(exists=False, toolkit="cocoa")[-1]
|
||||
self.assertEqual(last["expected"], "fail")
|
||||
|
||||
def test_missing_paths(self):
|
||||
"""
|
||||
Test paths that don't exist raise an exception in strict mode.
|
||||
|
@ -58,17 +89,43 @@ class TestTestManifest(unittest.TestCase):
|
|||
|
||||
shutil.rmtree(tempdir)
|
||||
|
||||
def test_missing_paths_toml(self):
|
||||
"""
|
||||
Test paths that don't exist raise an exception in strict mode. (TOML)
|
||||
"""
|
||||
tempdir = tempfile.mkdtemp()
|
||||
|
||||
missing_path = os.path.join(here, "missing-path.toml")
|
||||
manifest = TestManifest(manifests=(missing_path,), strict=True, use_toml=True)
|
||||
self.assertRaises(IOError, manifest.active_tests)
|
||||
self.assertRaises(IOError, manifest.copy, tempdir)
|
||||
self.assertRaises(IOError, manifest.update, tempdir)
|
||||
|
||||
shutil.rmtree(tempdir)
|
||||
|
||||
def test_comments(self):
|
||||
"""
|
||||
ensure comments work, see
|
||||
https://bugzilla.mozilla.org/show_bug.cgi?id=813674
|
||||
"""
|
||||
comment_example = os.path.join(here, "comment-example.ini")
|
||||
comment_example = os.path.join(here, "comment-example.toml")
|
||||
manifest = TestManifest(manifests=(comment_example,))
|
||||
self.assertEqual(len(manifest.tests), 8)
|
||||
names = [i["name"] for i in manifest.tests]
|
||||
self.assertFalse("test_0202_app_launch_apply_update_dirlocked.js" in names)
|
||||
|
||||
def test_comments_toml(self):
|
||||
"""
|
||||
ensure comments work, see
|
||||
https://bugzilla.mozilla.org/show_bug.cgi?id=813674
|
||||
(TOML)
|
||||
"""
|
||||
comment_example = os.path.join(here, "comment-example.toml")
|
||||
manifest = TestManifest(manifests=(comment_example,), use_toml=True)
|
||||
self.assertEqual(len(manifest.tests), 8)
|
||||
names = [i["name"] for i in manifest.tests]
|
||||
self.assertFalse("test_0202_app_launch_apply_update_dirlocked.js" in names)
|
||||
|
||||
def test_manifest_subsuites(self):
|
||||
"""
|
||||
test subsuites and conditional subsuites
|
||||
|
@ -103,6 +160,40 @@ class TestTestManifest(unittest.TestCase):
|
|||
with self.assertRaises(ParseError):
|
||||
manifest.active_tests(exists=False, filters=[subsuite("foo")], **info)
|
||||
|
||||
def test_manifest_subsuites_toml(self):
|
||||
"""
|
||||
test subsuites and conditional subsuites (TOML)
|
||||
"""
|
||||
relative_path = os.path.join(here, "subsuite.toml")
|
||||
manifest = TestManifest(manifests=(relative_path,), use_toml=True)
|
||||
info = {"foo": "bar"}
|
||||
|
||||
# 6 tests total
|
||||
tests = manifest.active_tests(exists=False, **info)
|
||||
self.assertEqual(len(tests), 6)
|
||||
|
||||
# only 3 tests for subsuite bar when foo==bar
|
||||
tests = manifest.active_tests(exists=False, filters=[subsuite("bar")], **info)
|
||||
self.assertEqual(len(tests), 3)
|
||||
|
||||
# only 1 test for subsuite baz, regardless of conditions
|
||||
other = {"something": "else"}
|
||||
tests = manifest.active_tests(exists=False, filters=[subsuite("baz")], **info)
|
||||
self.assertEqual(len(tests), 1)
|
||||
tests = manifest.active_tests(exists=False, filters=[subsuite("baz")], **other)
|
||||
self.assertEqual(len(tests), 1)
|
||||
|
||||
# 4 tests match when the condition doesn't match (all tests except
|
||||
# the unconditional subsuite)
|
||||
info = {"foo": "blah"}
|
||||
tests = manifest.active_tests(exists=False, filters=[subsuite()], **info)
|
||||
self.assertEqual(len(tests), 5)
|
||||
|
||||
# test for illegal subsuite value
|
||||
manifest.tests[0]["subsuite"] = 'subsuite=bar,foo=="bar",type="nothing"'
|
||||
with self.assertRaises(ParseError):
|
||||
manifest.active_tests(exists=False, filters=[subsuite("foo")], **info)
|
||||
|
||||
def test_none_and_empty_manifest(self):
|
||||
"""
|
||||
Test TestManifest for None and empty manifest, see
|
||||
|
@ -116,6 +207,20 @@ class TestTestManifest(unittest.TestCase):
|
|||
self.assertEqual(len(empty_manifest.test_paths()), 0)
|
||||
self.assertEqual(len(empty_manifest.active_tests()), 0)
|
||||
|
||||
def test_none_and_empty_manifest_toml(self):
|
||||
"""
|
||||
Test TestManifest for None and empty manifest, see
|
||||
https://bugzilla.mozilla.org/show_bug.cgi?id=1087682
|
||||
(TOML)
|
||||
"""
|
||||
none_manifest = TestManifest(manifests=None, strict=False, use_toml=True)
|
||||
self.assertEqual(len(none_manifest.test_paths()), 0)
|
||||
self.assertEqual(len(none_manifest.active_tests()), 0)
|
||||
|
||||
empty_manifest = TestManifest(manifests=[], strict=False)
|
||||
self.assertEqual(len(empty_manifest.test_paths()), 0)
|
||||
self.assertEqual(len(empty_manifest.active_tests()), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mozunit.main()
|
||||
|
|
|
@ -4,13 +4,13 @@
|
|||
Test how our utility functions are working.
|
||||
"""
|
||||
|
||||
from io import StringIO
|
||||
from textwrap import dedent
|
||||
|
||||
import mozunit
|
||||
import pytest
|
||||
from manifestparser import read_ini
|
||||
from manifestparser.util import evaluate_list_from_string
|
||||
from six import StringIO
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
['test_sub.js']
|
|
@ -0,0 +1,4 @@
|
|||
['test_1.js']
|
||||
['test_2.js']
|
||||
['test_3.js']
|
||||
["include:subdir/manifest.toml"]
|
|
@ -0,0 +1,3 @@
|
|||
['test_2.js']
|
||||
['test_3.js']
|
||||
["include:subdir/manifest.toml"]
|
|
@ -0,0 +1,5 @@
|
|||
['test_1.js']
|
||||
['test_2.js']
|
||||
['test_3.js']
|
||||
['test_notappearinginthisfilm.js']
|
||||
["include:subdir/manifest.toml"]
|
|
@ -13,7 +13,6 @@ import posixpath
|
|||
import sys
|
||||
|
||||
import mozinfo
|
||||
from manifestparser import TestManifest
|
||||
|
||||
|
||||
class SingleTestMixin(object):
|
||||
|
@ -69,6 +68,9 @@ class SingleTestMixin(object):
|
|||
is_fission = "fission.autostart=true" in self.config.get("extra_prefs", [])
|
||||
tests_by_path = {}
|
||||
all_disabled = []
|
||||
# HACK: import here so we don't need import for rest of class
|
||||
from manifestparser import TestManifest
|
||||
|
||||
for (path, suite) in manifests:
|
||||
if os.path.exists(path):
|
||||
man = TestManifest([path], strict=False)
|
||||
|
|
|
@ -18,6 +18,7 @@ pycrypto==2.6.1
|
|||
pyflakes==0.6.1
|
||||
pylint==0.27.0
|
||||
simplejson==2.1.1
|
||||
tomlkit==0.11.8
|
||||
unittest2==0.5.1
|
||||
virtualenv==1.5.1
|
||||
wsgiref==0.1.2
|
||||
|
|
|
@ -15,7 +15,15 @@ import mozharness
|
|||
|
||||
version = mozharness.version_string
|
||||
|
||||
dependencies = ["virtualenv", "mock", "coverage", "nose", "pylint", "pyflakes"]
|
||||
dependencies = [
|
||||
"virtualenv",
|
||||
"mock",
|
||||
"coverage",
|
||||
"nose",
|
||||
"pylint",
|
||||
"pyflakes",
|
||||
"tomlkit",
|
||||
]
|
||||
try:
|
||||
import json
|
||||
except ImportError:
|
||||
|
|
|
@ -1227,6 +1227,18 @@ files = [
|
|||
{file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomlkit"
|
||||
version = "0.11.8"
|
||||
description = "Style preserving TOML library"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "tomlkit-0.11.8-py3-none-any.whl", hash = "sha256:8c726c4c202bdb148667835f68d68780b9a003a9ec34167b6c673b38eff2a171"},
|
||||
{file = "tomlkit-0.11.8.tar.gz", hash = "sha256:9330fc7faa1db67b541b28e62018c17d20be733177d290a13b24c62d1614e0c3"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
version = "4.62.3"
|
||||
|
@ -1403,4 +1415,4 @@ testing = ["func-timeout", "jaraco.itertools", "pytest (>=4.6)", "pytest-black (
|
|||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.7"
|
||||
content-hash = "1287ae37af1bcab84a39b20585a69da3a01bd47c37718f2af47e4e9c886171b0"
|
||||
content-hash = "c409903aba82ee97e2d690aeaf190c01fe6c26adbc472572dc2fd9fa2cb7cc8f"
|
||||
|
|
|
@ -45,6 +45,7 @@ taskcluster==44.2.2
|
|||
taskcluster-taskgraph==5.7.0
|
||||
taskcluster-urls==13.0.1
|
||||
toml==0.10.2
|
||||
tomlkit==0.11.8
|
||||
tqdm==4.62.3
|
||||
urllib3==1.26
|
||||
voluptuous==0.12.1
|
||||
|
|
|
@ -374,6 +374,9 @@ text-unidecode==1.3 ; python_version >= "3.7" and python_version < "4.0" \
|
|||
toml==0.10.2 ; python_version >= "3.7" and python_version < "4.0" \
|
||||
--hash=sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b \
|
||||
--hash=sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f
|
||||
tomlkit==0.11.8 ; python_version >= "3.7" and python_version < "4.0" \
|
||||
--hash=sha256:8c726c4c202bdb148667835f68d68780b9a003a9ec34167b6c673b38eff2a171 \
|
||||
--hash=sha256:9330fc7faa1db67b541b28e62018c17d20be733177d290a13b24c62d1614e0c3
|
||||
tqdm==4.62.3 ; python_version >= "3.7" and python_version < "4.0" \
|
||||
--hash=sha256:8dd278a422499cd6b727e6ae4061c40b48fce8b76d1ccbf5d34fca9b7f925b0c \
|
||||
--hash=sha256:d359de7217506c9851b7869f3708d8ee53ed70a1b8edbba4dbcb47442592920d
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
Copyright (c) 2018 Sébastien Eustace
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@ -0,0 +1,70 @@
|
|||
Metadata-Version: 2.1
|
||||
Name: tomlkit
|
||||
Version: 0.11.8
|
||||
Summary: Style preserving TOML library
|
||||
Home-page: https://github.com/sdispater/tomlkit
|
||||
License: MIT
|
||||
Author: Sébastien Eustace
|
||||
Author-email: sebastien@eustace.io
|
||||
Requires-Python: >=3.7
|
||||
Classifier: License :: OSI Approved :: MIT License
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Programming Language :: Python :: 3.7
|
||||
Classifier: Programming Language :: Python :: 3.8
|
||||
Classifier: Programming Language :: Python :: 3.9
|
||||
Classifier: Programming Language :: Python :: 3.10
|
||||
Classifier: Programming Language :: Python :: 3.11
|
||||
Project-URL: Repository, https://github.com/sdispater/tomlkit
|
||||
Description-Content-Type: text/markdown
|
||||
|
||||
[github_release]: https://img.shields.io/github/release/sdispater/tomlkit.svg?logo=github&logoColor=white
|
||||
[pypi_version]: https://img.shields.io/pypi/v/tomlkit.svg?logo=python&logoColor=white
|
||||
[python_versions]: https://img.shields.io/pypi/pyversions/tomlkit.svg?logo=python&logoColor=white
|
||||
[github_license]: https://img.shields.io/github/license/sdispater/tomlkit.svg?logo=github&logoColor=white
|
||||
[github_action]: https://github.com/sdispater/tomlkit/actions/workflows/tests.yml/badge.svg
|
||||
|
||||
[![GitHub Release][github_release]](https://github.com/sdispater/tomlkit/releases/)
|
||||
[![PyPI Version][pypi_version]](https://pypi.org/project/tomlkit/)
|
||||
[![Python Versions][python_versions]](https://pypi.org/project/tomlkit/)
|
||||
[![License][github_license]](https://github.com/sdispater/tomlkit/blob/master/LICENSE)
|
||||
<br>
|
||||
[![Tests][github_action]](https://github.com/sdispater/tomlkit/actions/workflows/tests.yml)
|
||||
|
||||
# TOML Kit - Style-preserving TOML library for Python
|
||||
|
||||
TOML Kit is a **1.0.0-compliant** [TOML](https://toml.io/) library.
|
||||
|
||||
It includes a parser that preserves all comments, indentations, whitespace and internal element ordering,
|
||||
and makes them accessible and editable via an intuitive API.
|
||||
|
||||
You can also create new TOML documents from scratch using the provided helpers.
|
||||
|
||||
Part of the implementation has been adapted, improved and fixed from [Molten](https://github.com/LeopoldArkham/Molten).
|
||||
|
||||
## Usage
|
||||
|
||||
See the [documentation](https://github.com/sdispater/tomlkit/blob/master/docs/quickstart.rst) for more information.
|
||||
|
||||
## Installation
|
||||
|
||||
If you are using [Poetry](https://poetry.eustace.io),
|
||||
add `tomlkit` to your `pyproject.toml` file by using:
|
||||
|
||||
```bash
|
||||
poetry add tomlkit
|
||||
```
|
||||
|
||||
If not, you can use `pip`:
|
||||
|
||||
```bash
|
||||
pip install tomlkit
|
||||
```
|
||||
|
||||
## Running tests
|
||||
|
||||
Please clone the repo with submodules with the following command
|
||||
`git clone --recurse-submodules https://github.com/sdispater/tomlkit.git`.
|
||||
We need the submodule - `toml-test` for running the tests.
|
||||
|
||||
You can run the tests with `poetry run pytest -q tests`
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
tomlkit/__init__.py,sha256=_u6Tsd33N9hf-GhAhpqj6t0soNnTO5LP2uMdWwX9xG0,1148
|
||||
tomlkit/_compat.py,sha256=gp7P7qNh0yY1dg0wyjiCDbVwFTdUo7p0QwjV4T3Funs,513
|
||||
tomlkit/_utils.py,sha256=fX1n7boCi7_bBVBemaywk0eyxqhTYnZWPhCZHJgBZv8,4021
|
||||
tomlkit/api.py,sha256=DnIdqzAyi5FpCcRBGzX7We0wqdBjCYihe0TaGR0YA4E,7065
|
||||
tomlkit/container.py,sha256=nHeshsFne77La-LqOxMTKk9xyuF0-HrwfMlSwpgP7hs,28192
|
||||
tomlkit/exceptions.py,sha256=TdeHy9e9yiXI8oSR-eCxqtQOWBlyFgn7tTjvpCWAqTw,5487
|
||||
tomlkit/items.py,sha256=qQ0jyzSx0aplK6qdpIU-7jEGC5bHo4TMu6bPxnkY-Ts,51695
|
||||
tomlkit/parser.py,sha256=cBuC9T3ZrvKVtwzK0PFEo5roJqru7A0cXMFhKlcb_LU,37839
|
||||
tomlkit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
tomlkit/source.py,sha256=nwRrAUkJ2CyDDKFs4jo9o0RpNMNaNNoLDFiwo4ZNZTM,4823
|
||||
tomlkit/toml_char.py,sha256=w3sQZ0dolZ1qjZ2Rxj_svvlpRNNGB_fjfBcYD0gFnDs,1291
|
||||
tomlkit/toml_document.py,sha256=OCTkWXd3P58EZT4SD8_ddc1YpkMaqtlS5_stHTBmMOI,110
|
||||
tomlkit/toml_file.py,sha256=4gVZvvs_Q1_soWaVxBo80rRzny849boXt2LzdMXQ04I,1599
|
||||
tomlkit-0.11.8.dist-info/LICENSE,sha256=8vm0YLpxnaZiat0mTTeC8nWk_3qrZ3vtoIszCRHiOts,1062
|
||||
tomlkit-0.11.8.dist-info/METADATA,sha256=zj4O4eLckCCdR1aTCYdhIH3wBf9SQUKx6tbrx4uAGOU,2704
|
||||
tomlkit-0.11.8.dist-info/WHEEL,sha256=7Z8_27uaHI_UZAc4Uox4PpBhQ9Y5_modZXWMxtUi4NU,88
|
||||
tomlkit-0.11.8.dist-info/RECORD,,
|
|
@ -0,0 +1,4 @@
|
|||
Wheel-Version: 1.0
|
||||
Generator: poetry-core 1.5.2
|
||||
Root-Is-Purelib: true
|
||||
Tag: py3-none-any
|
|
@ -0,0 +1,55 @@
|
|||
from tomlkit.api import TOMLDocument
|
||||
from tomlkit.api import aot
|
||||
from tomlkit.api import array
|
||||
from tomlkit.api import boolean
|
||||
from tomlkit.api import comment
|
||||
from tomlkit.api import date
|
||||
from tomlkit.api import datetime
|
||||
from tomlkit.api import document
|
||||
from tomlkit.api import dump
|
||||
from tomlkit.api import dumps
|
||||
from tomlkit.api import float_
|
||||
from tomlkit.api import inline_table
|
||||
from tomlkit.api import integer
|
||||
from tomlkit.api import item
|
||||
from tomlkit.api import key
|
||||
from tomlkit.api import key_value
|
||||
from tomlkit.api import load
|
||||
from tomlkit.api import loads
|
||||
from tomlkit.api import nl
|
||||
from tomlkit.api import parse
|
||||
from tomlkit.api import string
|
||||
from tomlkit.api import table
|
||||
from tomlkit.api import time
|
||||
from tomlkit.api import value
|
||||
from tomlkit.api import ws
|
||||
|
||||
|
||||
__version__ = "0.11.8"
|
||||
__all__ = [
|
||||
"aot",
|
||||
"array",
|
||||
"boolean",
|
||||
"comment",
|
||||
"date",
|
||||
"datetime",
|
||||
"document",
|
||||
"dump",
|
||||
"dumps",
|
||||
"float_",
|
||||
"inline_table",
|
||||
"integer",
|
||||
"item",
|
||||
"key",
|
||||
"key_value",
|
||||
"load",
|
||||
"loads",
|
||||
"nl",
|
||||
"parse",
|
||||
"string",
|
||||
"table",
|
||||
"time",
|
||||
"TOMLDocument",
|
||||
"value",
|
||||
"ws",
|
||||
]
|
|
@ -0,0 +1,22 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import sys
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
PY38 = sys.version_info >= (3, 8)
|
||||
|
||||
|
||||
def decode(string: Any, encodings: list[str] | None = None):
|
||||
if not isinstance(string, bytes):
|
||||
return string
|
||||
|
||||
encodings = encodings or ["utf-8", "latin1", "ascii"]
|
||||
|
||||
for encoding in encodings:
|
||||
with contextlib.suppress(UnicodeEncodeError, UnicodeDecodeError):
|
||||
return string.decode(encoding)
|
||||
|
||||
return string.decode(encodings[0], errors="ignore")
|
|
@ -0,0 +1,156 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import date
|
||||
from datetime import datetime
|
||||
from datetime import time
|
||||
from datetime import timedelta
|
||||
from datetime import timezone
|
||||
from typing import Collection
|
||||
|
||||
from tomlkit._compat import decode
|
||||
|
||||
|
||||
RFC_3339_LOOSE = re.compile(
|
||||
"^"
|
||||
r"(([0-9]+)-(\d{2})-(\d{2}))?" # Date
|
||||
"("
|
||||
"([Tt ])?" # Separator
|
||||
r"(\d{2}):(\d{2}):(\d{2})(\.([0-9]+))?" # Time
|
||||
r"(([Zz])|([\+|\-]([01][0-9]|2[0-3]):([0-5][0-9])))?" # Timezone
|
||||
")?"
|
||||
"$"
|
||||
)
|
||||
|
||||
RFC_3339_DATETIME = re.compile(
|
||||
"^"
|
||||
"([0-9]+)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])" # Date
|
||||
"[Tt ]" # Separator
|
||||
r"([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60)(\.([0-9]+))?" # Time
|
||||
r"(([Zz])|([\+|\-]([01][0-9]|2[0-3]):([0-5][0-9])))?" # Timezone
|
||||
"$"
|
||||
)
|
||||
|
||||
RFC_3339_DATE = re.compile("^([0-9]+)-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])$")
|
||||
|
||||
RFC_3339_TIME = re.compile(
|
||||
r"^([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60)(\.([0-9]+))?$"
|
||||
)
|
||||
|
||||
_utc = timezone(timedelta(), "UTC")
|
||||
|
||||
|
||||
def parse_rfc3339(string: str) -> datetime | date | time:
|
||||
m = RFC_3339_DATETIME.match(string)
|
||||
if m:
|
||||
year = int(m.group(1))
|
||||
month = int(m.group(2))
|
||||
day = int(m.group(3))
|
||||
hour = int(m.group(4))
|
||||
minute = int(m.group(5))
|
||||
second = int(m.group(6))
|
||||
microsecond = 0
|
||||
|
||||
if m.group(7):
|
||||
microsecond = int((f"{m.group(8):<06s}")[:6])
|
||||
|
||||
if m.group(9):
|
||||
# Timezone
|
||||
tz = m.group(9)
|
||||
if tz.upper() == "Z":
|
||||
tzinfo = _utc
|
||||
else:
|
||||
sign = m.group(11)[0]
|
||||
hour_offset, minute_offset = int(m.group(12)), int(m.group(13))
|
||||
offset = timedelta(seconds=hour_offset * 3600 + minute_offset * 60)
|
||||
if sign == "-":
|
||||
offset = -offset
|
||||
|
||||
tzinfo = timezone(offset, f"{sign}{m.group(12)}:{m.group(13)}")
|
||||
|
||||
return datetime(
|
||||
year, month, day, hour, minute, second, microsecond, tzinfo=tzinfo
|
||||
)
|
||||
else:
|
||||
return datetime(year, month, day, hour, minute, second, microsecond)
|
||||
|
||||
m = RFC_3339_DATE.match(string)
|
||||
if m:
|
||||
year = int(m.group(1))
|
||||
month = int(m.group(2))
|
||||
day = int(m.group(3))
|
||||
|
||||
return date(year, month, day)
|
||||
|
||||
m = RFC_3339_TIME.match(string)
|
||||
if m:
|
||||
hour = int(m.group(1))
|
||||
minute = int(m.group(2))
|
||||
second = int(m.group(3))
|
||||
microsecond = 0
|
||||
|
||||
if m.group(4):
|
||||
microsecond = int((f"{m.group(5):<06s}")[:6])
|
||||
|
||||
return time(hour, minute, second, microsecond)
|
||||
|
||||
raise ValueError("Invalid RFC 339 string")
|
||||
|
||||
|
||||
# https://toml.io/en/v1.0.0#string
|
||||
CONTROL_CHARS = frozenset(chr(c) for c in range(0x20)) | {chr(0x7F)}
|
||||
_escaped = {
|
||||
"b": "\b",
|
||||
"t": "\t",
|
||||
"n": "\n",
|
||||
"f": "\f",
|
||||
"r": "\r",
|
||||
'"': '"',
|
||||
"\\": "\\",
|
||||
}
|
||||
_compact_escapes = {
|
||||
**{v: f"\\{k}" for k, v in _escaped.items()},
|
||||
'"""': '""\\"',
|
||||
}
|
||||
_basic_escapes = CONTROL_CHARS | {'"', "\\"}
|
||||
|
||||
|
||||
def _unicode_escape(seq: str) -> str:
|
||||
return "".join(f"\\u{ord(c):04x}" for c in seq)
|
||||
|
||||
|
||||
def escape_string(s: str, escape_sequences: Collection[str] = _basic_escapes) -> str:
|
||||
s = decode(s)
|
||||
|
||||
res = []
|
||||
start = 0
|
||||
|
||||
def flush(inc=1):
|
||||
if start != i:
|
||||
res.append(s[start:i])
|
||||
|
||||
return i + inc
|
||||
|
||||
i = 0
|
||||
while i < len(s):
|
||||
for seq in escape_sequences:
|
||||
seq_len = len(seq)
|
||||
if s[i:].startswith(seq):
|
||||
start = flush(seq_len)
|
||||
res.append(_compact_escapes.get(seq) or _unicode_escape(seq))
|
||||
i += seq_len - 1 # fast-forward escape sequence
|
||||
i += 1
|
||||
|
||||
flush()
|
||||
|
||||
return "".join(res)
|
||||
|
||||
|
||||
def merge_dicts(d1: dict, d2: dict) -> dict:
|
||||
for k, v in d2.items():
|
||||
if k in d1 and isinstance(d1[k], dict) and isinstance(v, Mapping):
|
||||
merge_dicts(d1[k], v)
|
||||
else:
|
||||
d1[k] = d2[k]
|
|
@ -0,0 +1,286 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime as _datetime
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import IO
|
||||
from typing import Iterable
|
||||
|
||||
from tomlkit._utils import parse_rfc3339
|
||||
from tomlkit.container import Container
|
||||
from tomlkit.exceptions import UnexpectedCharError
|
||||
from tomlkit.items import AoT
|
||||
from tomlkit.items import Array
|
||||
from tomlkit.items import Bool
|
||||
from tomlkit.items import Comment
|
||||
from tomlkit.items import Date
|
||||
from tomlkit.items import DateTime
|
||||
from tomlkit.items import DottedKey
|
||||
from tomlkit.items import Float
|
||||
from tomlkit.items import InlineTable
|
||||
from tomlkit.items import Integer
|
||||
from tomlkit.items import Item as _Item
|
||||
from tomlkit.items import Key
|
||||
from tomlkit.items import SingleKey
|
||||
from tomlkit.items import String
|
||||
from tomlkit.items import StringType as _StringType
|
||||
from tomlkit.items import Table
|
||||
from tomlkit.items import Time
|
||||
from tomlkit.items import Trivia
|
||||
from tomlkit.items import Whitespace
|
||||
from tomlkit.items import item
|
||||
from tomlkit.parser import Parser
|
||||
from tomlkit.toml_document import TOMLDocument
|
||||
|
||||
|
||||
def loads(string: str | bytes) -> TOMLDocument:
|
||||
"""
|
||||
Parses a string into a TOMLDocument.
|
||||
|
||||
Alias for parse().
|
||||
"""
|
||||
return parse(string)
|
||||
|
||||
|
||||
def dumps(data: Mapping, sort_keys: bool = False) -> str:
|
||||
"""
|
||||
Dumps a TOMLDocument into a string.
|
||||
"""
|
||||
if not isinstance(data, Container) and isinstance(data, Mapping):
|
||||
data = item(dict(data), _sort_keys=sort_keys)
|
||||
|
||||
try:
|
||||
# data should be a `Container` (and therefore implement `as_string`)
|
||||
# for all type safe invocations of this function
|
||||
return data.as_string() # type: ignore[attr-defined]
|
||||
except AttributeError as ex:
|
||||
msg = f"Expecting Mapping or TOML Container, {type(data)} given"
|
||||
raise TypeError(msg) from ex
|
||||
|
||||
|
||||
def load(fp: IO[str] | IO[bytes]) -> TOMLDocument:
|
||||
"""
|
||||
Load toml document from a file-like object.
|
||||
"""
|
||||
return parse(fp.read())
|
||||
|
||||
|
||||
def dump(data: Mapping, fp: IO[str], *, sort_keys: bool = False) -> None:
|
||||
"""
|
||||
Dump a TOMLDocument into a writable file stream.
|
||||
|
||||
:param data: a dict-like object to dump
|
||||
:param sort_keys: if true, sort the keys in alphabetic order
|
||||
"""
|
||||
fp.write(dumps(data, sort_keys=sort_keys))
|
||||
|
||||
|
||||
def parse(string: str | bytes) -> TOMLDocument:
|
||||
"""
|
||||
Parses a string or bytes into a TOMLDocument.
|
||||
"""
|
||||
return Parser(string).parse()
|
||||
|
||||
|
||||
def document() -> TOMLDocument:
|
||||
"""
|
||||
Returns a new TOMLDocument instance.
|
||||
"""
|
||||
return TOMLDocument()
|
||||
|
||||
|
||||
# Items
|
||||
def integer(raw: str | int) -> Integer:
|
||||
"""Create an integer item from a number or string."""
|
||||
return item(int(raw))
|
||||
|
||||
|
||||
def float_(raw: str | float) -> Float:
|
||||
"""Create an float item from a number or string."""
|
||||
return item(float(raw))
|
||||
|
||||
|
||||
def boolean(raw: str) -> Bool:
|
||||
"""Turn `true` or `false` into a boolean item."""
|
||||
return item(raw == "true")
|
||||
|
||||
|
||||
def string(
|
||||
raw: str,
|
||||
*,
|
||||
literal: bool = False,
|
||||
multiline: bool = False,
|
||||
escape: bool = True,
|
||||
) -> String:
|
||||
"""Create a string item.
|
||||
|
||||
By default, this function will create *single line basic* strings, but
|
||||
boolean flags (e.g. ``literal=True`` and/or ``multiline=True``)
|
||||
can be used for personalization.
|
||||
|
||||
For more information, please check the spec: `<https://toml.io/en/v1.0.0#string>`__.
|
||||
|
||||
Common escaping rules will be applied for basic strings.
|
||||
This can be controlled by explicitly setting ``escape=False``.
|
||||
Please note that, if you disable escaping, you will have to make sure that
|
||||
the given strings don't contain any forbidden character or sequence.
|
||||
"""
|
||||
type_ = _StringType.select(literal, multiline)
|
||||
return String.from_raw(raw, type_, escape)
|
||||
|
||||
|
||||
def date(raw: str) -> Date:
|
||||
"""Create a TOML date."""
|
||||
value = parse_rfc3339(raw)
|
||||
if not isinstance(value, _datetime.date):
|
||||
raise ValueError("date() only accepts date strings.")
|
||||
|
||||
return item(value)
|
||||
|
||||
|
||||
def time(raw: str) -> Time:
|
||||
"""Create a TOML time."""
|
||||
value = parse_rfc3339(raw)
|
||||
if not isinstance(value, _datetime.time):
|
||||
raise ValueError("time() only accepts time strings.")
|
||||
|
||||
return item(value)
|
||||
|
||||
|
||||
def datetime(raw: str) -> DateTime:
|
||||
"""Create a TOML datetime."""
|
||||
value = parse_rfc3339(raw)
|
||||
if not isinstance(value, _datetime.datetime):
|
||||
raise ValueError("datetime() only accepts datetime strings.")
|
||||
|
||||
return item(value)
|
||||
|
||||
|
||||
def array(raw: str = None) -> Array:
|
||||
"""Create an array item for its string representation.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> array("[1, 2, 3]") # Create from a string
|
||||
[1, 2, 3]
|
||||
>>> a = array()
|
||||
>>> a.extend([1, 2, 3]) # Create from a list
|
||||
>>> a
|
||||
[1, 2, 3]
|
||||
"""
|
||||
if raw is None:
|
||||
raw = "[]"
|
||||
|
||||
return value(raw)
|
||||
|
||||
|
||||
def table(is_super_table: bool | None = None) -> Table:
|
||||
"""Create an empty table.
|
||||
|
||||
:param is_super_table: if true, the table is a super table
|
||||
|
||||
:Example:
|
||||
|
||||
>>> doc = document()
|
||||
>>> foo = table(True)
|
||||
>>> bar = table()
|
||||
>>> bar.update({'x': 1})
|
||||
>>> foo.append('bar', bar)
|
||||
>>> doc.append('foo', foo)
|
||||
>>> print(doc.as_string())
|
||||
[foo.bar]
|
||||
x = 1
|
||||
"""
|
||||
return Table(Container(), Trivia(), False, is_super_table)
|
||||
|
||||
|
||||
def inline_table() -> InlineTable:
|
||||
"""Create an inline table.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> table = inline_table()
|
||||
>>> table.update({'x': 1, 'y': 2})
|
||||
>>> print(table.as_string())
|
||||
{x = 1, y = 2}
|
||||
"""
|
||||
return InlineTable(Container(), Trivia(), new=True)
|
||||
|
||||
|
||||
def aot() -> AoT:
|
||||
"""Create an array of table.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> doc = document()
|
||||
>>> aot = aot()
|
||||
>>> aot.append(item({'x': 1}))
|
||||
>>> doc.append('foo', aot)
|
||||
>>> print(doc.as_string())
|
||||
[[foo]]
|
||||
x = 1
|
||||
"""
|
||||
return AoT([])
|
||||
|
||||
|
||||
def key(k: str | Iterable[str]) -> Key:
|
||||
"""Create a key from a string. When a list of string is given,
|
||||
it will create a dotted key.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> doc = document()
|
||||
>>> doc.append(key('foo'), 1)
|
||||
>>> doc.append(key(['bar', 'baz']), 2)
|
||||
>>> print(doc.as_string())
|
||||
foo = 1
|
||||
bar.baz = 2
|
||||
"""
|
||||
if isinstance(k, str):
|
||||
return SingleKey(k)
|
||||
return DottedKey([key(_k) for _k in k])
|
||||
|
||||
|
||||
def value(raw: str) -> _Item:
|
||||
"""Parse a simple value from a string.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> value("1")
|
||||
1
|
||||
>>> value("true")
|
||||
True
|
||||
>>> value("[1, 2, 3]")
|
||||
[1, 2, 3]
|
||||
"""
|
||||
parser = Parser(raw)
|
||||
v = parser._parse_value()
|
||||
if not parser.end():
|
||||
raise parser.parse_error(UnexpectedCharError, char=parser._current)
|
||||
return v
|
||||
|
||||
|
||||
def key_value(src: str) -> tuple[Key, _Item]:
|
||||
"""Parse a key-value pair from a string.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> key_value("foo = 1")
|
||||
(Key('foo'), 1)
|
||||
"""
|
||||
return Parser(src)._parse_key_value()
|
||||
|
||||
|
||||
def ws(src: str) -> Whitespace:
|
||||
"""Create a whitespace from a string."""
|
||||
return Whitespace(src, fixed=True)
|
||||
|
||||
|
||||
def nl() -> Whitespace:
|
||||
"""Create a newline item."""
|
||||
return ws("\n")
|
||||
|
||||
|
||||
def comment(string: str) -> Comment:
|
||||
"""Create a comment item."""
|
||||
return Comment(Trivia(comment_ws=" ", comment="# " + string))
|
|
@ -0,0 +1,866 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
|
||||
from typing import Any
|
||||
from typing import Iterator
|
||||
|
||||
from tomlkit._compat import decode
|
||||
from tomlkit._utils import merge_dicts
|
||||
from tomlkit.exceptions import KeyAlreadyPresent
|
||||
from tomlkit.exceptions import NonExistentKey
|
||||
from tomlkit.exceptions import TOMLKitError
|
||||
from tomlkit.items import AoT
|
||||
from tomlkit.items import Comment
|
||||
from tomlkit.items import Item
|
||||
from tomlkit.items import Key
|
||||
from tomlkit.items import Null
|
||||
from tomlkit.items import SingleKey
|
||||
from tomlkit.items import Table
|
||||
from tomlkit.items import Trivia
|
||||
from tomlkit.items import Whitespace
|
||||
from tomlkit.items import _CustomDict
|
||||
from tomlkit.items import item as _item
|
||||
|
||||
|
||||
_NOT_SET = object()
|
||||
|
||||
|
||||
class Container(_CustomDict):
|
||||
"""
|
||||
A container for items within a TOMLDocument.
|
||||
|
||||
This class implements the `dict` interface with copy/deepcopy protocol.
|
||||
"""
|
||||
|
||||
def __init__(self, parsed: bool = False) -> None:
|
||||
self._map: dict[SingleKey, int | tuple[int, ...]] = {}
|
||||
self._body: list[tuple[Key | None, Item]] = []
|
||||
self._parsed = parsed
|
||||
self._table_keys = []
|
||||
|
||||
@property
|
||||
def body(self) -> list[tuple[Key | None, Item]]:
|
||||
return self._body
|
||||
|
||||
def unwrap(self) -> dict[str, Any]:
|
||||
unwrapped = {}
|
||||
for k, v in self.items():
|
||||
if k is None:
|
||||
continue
|
||||
|
||||
if isinstance(k, Key):
|
||||
k = k.key
|
||||
|
||||
if hasattr(v, "unwrap"):
|
||||
v = v.unwrap()
|
||||
|
||||
if k in unwrapped:
|
||||
merge_dicts(unwrapped[k], v)
|
||||
else:
|
||||
unwrapped[k] = v
|
||||
|
||||
return unwrapped
|
||||
|
||||
@property
|
||||
def value(self) -> dict[str, Any]:
|
||||
d = {}
|
||||
for k, v in self._body:
|
||||
if k is None:
|
||||
continue
|
||||
|
||||
k = k.key
|
||||
v = v.value
|
||||
|
||||
if isinstance(v, Container):
|
||||
v = v.value
|
||||
|
||||
if k in d:
|
||||
merge_dicts(d[k], v)
|
||||
else:
|
||||
d[k] = v
|
||||
|
||||
return d
|
||||
|
||||
def parsing(self, parsing: bool) -> None:
|
||||
self._parsed = parsing
|
||||
|
||||
for _, v in self._body:
|
||||
if isinstance(v, Table):
|
||||
v.value.parsing(parsing)
|
||||
elif isinstance(v, AoT):
|
||||
for t in v.body:
|
||||
t.value.parsing(parsing)
|
||||
|
||||
def add(self, key: Key | Item | str, item: Item | None = None) -> Container:
|
||||
"""
|
||||
Adds an item to the current Container.
|
||||
|
||||
:Example:
|
||||
|
||||
>>> # add a key-value pair
|
||||
>>> doc.add('key', 'value')
|
||||
>>> # add a comment or whitespace or newline
|
||||
>>> doc.add(comment('# comment'))
|
||||
"""
|
||||
if item is None:
|
||||
if not isinstance(key, (Comment, Whitespace)):
|
||||
raise ValueError(
|
||||
"Non comment/whitespace items must have an associated key"
|
||||
)
|
||||
|
||||
key, item = None, key
|
||||
|
||||
return self.append(key, item)
|
||||
|
||||
def _handle_dotted_key(self, key: Key, value: Item) -> None:
|
||||
if isinstance(value, (Table, AoT)):
|
||||
raise TOMLKitError("Can't add a table to a dotted key")
|
||||
name, *mid, last = key
|
||||
name._dotted = True
|
||||
table = current = Table(Container(True), Trivia(), False, is_super_table=True)
|
||||
for _name in mid:
|
||||
_name._dotted = True
|
||||
new_table = Table(Container(True), Trivia(), False, is_super_table=True)
|
||||
current.append(_name, new_table)
|
||||
current = new_table
|
||||
|
||||
last.sep = key.sep
|
||||
current.append(last, value)
|
||||
|
||||
self.append(name, table)
|
||||
return
|
||||
|
||||
def _get_last_index_before_table(self) -> int:
|
||||
last_index = -1
|
||||
for i, (k, v) in enumerate(self._body):
|
||||
if isinstance(v, Null):
|
||||
continue # Null elements are inserted after deletion
|
||||
|
||||
if isinstance(v, Whitespace) and not v.is_fixed():
|
||||
continue
|
||||
|
||||
if isinstance(v, (Table, AoT)) and not k.is_dotted():
|
||||
break
|
||||
last_index = i
|
||||
return last_index + 1
|
||||
|
||||
def append(self, key: Key | str | None, item: Item) -> Container:
|
||||
"""Similar to :meth:`add` but both key and value must be given."""
|
||||
if not isinstance(key, Key) and key is not None:
|
||||
key = SingleKey(key)
|
||||
|
||||
if not isinstance(item, Item):
|
||||
item = _item(item)
|
||||
|
||||
if key is not None and key.is_multi():
|
||||
self._handle_dotted_key(key, item)
|
||||
return self
|
||||
|
||||
if isinstance(item, (AoT, Table)) and item.name is None:
|
||||
item.name = key.key
|
||||
|
||||
prev = self._previous_item()
|
||||
prev_ws = isinstance(prev, Whitespace) or ends_with_whitespace(prev)
|
||||
if isinstance(item, Table):
|
||||
if not self._parsed:
|
||||
item.invalidate_display_name()
|
||||
if (
|
||||
self._body
|
||||
and not (self._parsed or item.trivia.indent or prev_ws)
|
||||
and not key.is_dotted()
|
||||
):
|
||||
item.trivia.indent = "\n"
|
||||
|
||||
if isinstance(item, AoT) and self._body and not self._parsed:
|
||||
item.invalidate_display_name()
|
||||
if item and not ("\n" in item[0].trivia.indent or prev_ws):
|
||||
item[0].trivia.indent = "\n" + item[0].trivia.indent
|
||||
|
||||
if key is not None and key in self:
|
||||
current_idx = self._map[key]
|
||||
if isinstance(current_idx, tuple):
|
||||
current_body_element = self._body[current_idx[-1]]
|
||||
else:
|
||||
current_body_element = self._body[current_idx]
|
||||
|
||||
current = current_body_element[1]
|
||||
|
||||
if isinstance(item, Table):
|
||||
if not isinstance(current, (Table, AoT)):
|
||||
raise KeyAlreadyPresent(key)
|
||||
|
||||
if item.is_aot_element():
|
||||
# New AoT element found later on
|
||||
# Adding it to the current AoT
|
||||
if not isinstance(current, AoT):
|
||||
current = AoT([current, item], parsed=self._parsed)
|
||||
|
||||
self._replace(key, key, current)
|
||||
else:
|
||||
current.append(item)
|
||||
|
||||
return self
|
||||
elif current.is_aot():
|
||||
if not item.is_aot_element():
|
||||
# Tried to define a table after an AoT with the same name.
|
||||
raise KeyAlreadyPresent(key)
|
||||
|
||||
current.append(item)
|
||||
|
||||
return self
|
||||
elif current.is_super_table():
|
||||
if item.is_super_table():
|
||||
# We need to merge both super tables
|
||||
if (
|
||||
self._table_keys[-1] != current_body_element[0]
|
||||
or key.is_dotted()
|
||||
or current_body_element[0].is_dotted()
|
||||
):
|
||||
if key.is_dotted() and not self._parsed:
|
||||
idx = self._get_last_index_before_table()
|
||||
else:
|
||||
idx = len(self._body)
|
||||
|
||||
if idx < len(self._body):
|
||||
self._insert_at(idx, key, item)
|
||||
else:
|
||||
self._raw_append(key, item)
|
||||
|
||||
# Building a temporary proxy to check for errors
|
||||
OutOfOrderTableProxy(self, self._map[key])
|
||||
|
||||
return self
|
||||
|
||||
# Create a new element to replace the old one
|
||||
current = copy.deepcopy(current)
|
||||
for k, v in item.value.body:
|
||||
current.append(k, v)
|
||||
self._body[
|
||||
current_idx[-1]
|
||||
if isinstance(current_idx, tuple)
|
||||
else current_idx
|
||||
] = (current_body_element[0], current)
|
||||
|
||||
return self
|
||||
elif current_body_element[0].is_dotted():
|
||||
raise TOMLKitError("Redefinition of an existing table")
|
||||
elif not item.is_super_table():
|
||||
raise KeyAlreadyPresent(key)
|
||||
elif isinstance(item, AoT):
|
||||
if not isinstance(current, AoT):
|
||||
# Tried to define an AoT after a table with the same name.
|
||||
raise KeyAlreadyPresent(key)
|
||||
|
||||
for table in item.body:
|
||||
current.append(table)
|
||||
|
||||
return self
|
||||
else:
|
||||
raise KeyAlreadyPresent(key)
|
||||
|
||||
is_table = isinstance(item, (Table, AoT))
|
||||
if (
|
||||
key is not None
|
||||
and self._body
|
||||
and not self._parsed
|
||||
and (not is_table or key.is_dotted())
|
||||
):
|
||||
# If there is already at least one table in the current container
|
||||
# and the given item is not a table, we need to find the last
|
||||
# item that is not a table and insert after it
|
||||
# If no such item exists, insert at the top of the table
|
||||
last_index = self._get_last_index_before_table()
|
||||
|
||||
if last_index < len(self._body):
|
||||
return self._insert_at(last_index, key, item)
|
||||
else:
|
||||
previous_item = self._body[-1][1]
|
||||
if not (
|
||||
isinstance(previous_item, Whitespace)
|
||||
or ends_with_whitespace(previous_item)
|
||||
or "\n" in previous_item.trivia.trail
|
||||
):
|
||||
previous_item.trivia.trail += "\n"
|
||||
|
||||
self._raw_append(key, item)
|
||||
return self
|
||||
|
||||
def _raw_append(self, key: Key, item: Item) -> None:
|
||||
if key in self._map:
|
||||
current_idx = self._map[key]
|
||||
if not isinstance(current_idx, tuple):
|
||||
current_idx = (current_idx,)
|
||||
|
||||
current = self._body[current_idx[-1]][1]
|
||||
if key is not None and not isinstance(current, Table):
|
||||
raise KeyAlreadyPresent(key)
|
||||
|
||||
self._map[key] = current_idx + (len(self._body),)
|
||||
else:
|
||||
self._map[key] = len(self._body)
|
||||
|
||||
self._body.append((key, item))
|
||||
if item.is_table():
|
||||
self._table_keys.append(key)
|
||||
|
||||
if key is not None:
|
||||
dict.__setitem__(self, key.key, item.value)
|
||||
|
||||
return self
|
||||
|
||||
def _remove_at(self, idx: int) -> None:
|
||||
key = self._body[idx][0]
|
||||
index = self._map.get(key)
|
||||
if index is None:
|
||||
raise NonExistentKey(key)
|
||||
self._body[idx] = (None, Null())
|
||||
|
||||
if isinstance(index, tuple):
|
||||
index = list(index)
|
||||
index.remove(idx)
|
||||
if len(index) == 1:
|
||||
index = index.pop()
|
||||
else:
|
||||
index = tuple(index)
|
||||
self._map[key] = index
|
||||
else:
|
||||
dict.__delitem__(self, key.key)
|
||||
self._map.pop(key)
|
||||
|
||||
def remove(self, key: Key | str) -> Container:
|
||||
"""Remove a key from the container."""
|
||||
if not isinstance(key, Key):
|
||||
key = SingleKey(key)
|
||||
|
||||
idx = self._map.pop(key, None)
|
||||
if idx is None:
|
||||
raise NonExistentKey(key)
|
||||
|
||||
if isinstance(idx, tuple):
|
||||
for i in idx:
|
||||
self._body[i] = (None, Null())
|
||||
else:
|
||||
self._body[idx] = (None, Null())
|
||||
|
||||
dict.__delitem__(self, key.key)
|
||||
|
||||
return self
|
||||
|
||||
def _insert_after(
|
||||
self, key: Key | str, other_key: Key | str, item: Any
|
||||
) -> Container:
|
||||
if key is None:
|
||||
raise ValueError("Key cannot be null in insert_after()")
|
||||
|
||||
if key not in self:
|
||||
raise NonExistentKey(key)
|
||||
|
||||
if not isinstance(key, Key):
|
||||
key = SingleKey(key)
|
||||
|
||||
if not isinstance(other_key, Key):
|
||||
other_key = SingleKey(other_key)
|
||||
|
||||
item = _item(item)
|
||||
|
||||
idx = self._map[key]
|
||||
# Insert after the max index if there are many.
|
||||
if isinstance(idx, tuple):
|
||||
idx = max(idx)
|
||||
current_item = self._body[idx][1]
|
||||
if "\n" not in current_item.trivia.trail:
|
||||
current_item.trivia.trail += "\n"
|
||||
|
||||
# Increment indices after the current index
|
||||
for k, v in self._map.items():
|
||||
if isinstance(v, tuple):
|
||||
new_indices = []
|
||||
for v_ in v:
|
||||
if v_ > idx:
|
||||
v_ = v_ + 1
|
||||
|
||||
new_indices.append(v_)
|
||||
|
||||
self._map[k] = tuple(new_indices)
|
||||
elif v > idx:
|
||||
self._map[k] = v + 1
|
||||
|
||||
self._map[other_key] = idx + 1
|
||||
self._body.insert(idx + 1, (other_key, item))
|
||||
|
||||
if key is not None:
|
||||
dict.__setitem__(self, other_key.key, item.value)
|
||||
|
||||
return self
|
||||
|
||||
def _insert_at(self, idx: int, key: Key | str, item: Any) -> Container:
|
||||
if idx > len(self._body) - 1:
|
||||
raise ValueError(f"Unable to insert at position {idx}")
|
||||
|
||||
if not isinstance(key, Key):
|
||||
key = SingleKey(key)
|
||||
|
||||
item = _item(item)
|
||||
|
||||
if idx > 0:
|
||||
previous_item = self._body[idx - 1][1]
|
||||
if not (
|
||||
isinstance(previous_item, Whitespace)
|
||||
or ends_with_whitespace(previous_item)
|
||||
or isinstance(item, (AoT, Table))
|
||||
or "\n" in previous_item.trivia.trail
|
||||
):
|
||||
previous_item.trivia.trail += "\n"
|
||||
|
||||
# Increment indices after the current index
|
||||
for k, v in self._map.items():
|
||||
if isinstance(v, tuple):
|
||||
new_indices = []
|
||||
for v_ in v:
|
||||
if v_ >= idx:
|
||||
v_ = v_ + 1
|
||||
|
||||
new_indices.append(v_)
|
||||
|
||||
self._map[k] = tuple(new_indices)
|
||||
elif v >= idx:
|
||||
self._map[k] = v + 1
|
||||
|
||||
if key in self._map:
|
||||
current_idx = self._map[key]
|
||||
if not isinstance(current_idx, tuple):
|
||||
current_idx = (current_idx,)
|
||||
self._map[key] = current_idx + (idx,)
|
||||
else:
|
||||
self._map[key] = idx
|
||||
self._body.insert(idx, (key, item))
|
||||
|
||||
dict.__setitem__(self, key.key, item.value)
|
||||
|
||||
return self
|
||||
|
||||
def item(self, key: Key | str) -> Item:
|
||||
"""Get an item for the given key."""
|
||||
if not isinstance(key, Key):
|
||||
key = SingleKey(key)
|
||||
|
||||
idx = self._map.get(key)
|
||||
if idx is None:
|
||||
raise NonExistentKey(key)
|
||||
|
||||
if isinstance(idx, tuple):
|
||||
# The item we are getting is an out of order table
|
||||
# so we need a proxy to retrieve the proper objects
|
||||
# from the parent container
|
||||
return OutOfOrderTableProxy(self, idx)
|
||||
|
||||
return self._body[idx][1]
|
||||
|
||||
def last_item(self) -> Item | None:
|
||||
"""Get the last item."""
|
||||
if self._body:
|
||||
return self._body[-1][1]
|
||||
|
||||
def as_string(self) -> str:
|
||||
"""Render as TOML string."""
|
||||
s = ""
|
||||
for k, v in self._body:
|
||||
if k is not None:
|
||||
if isinstance(v, Table):
|
||||
s += self._render_table(k, v)
|
||||
elif isinstance(v, AoT):
|
||||
s += self._render_aot(k, v)
|
||||
else:
|
||||
s += self._render_simple_item(k, v)
|
||||
else:
|
||||
s += self._render_simple_item(k, v)
|
||||
|
||||
return s
|
||||
|
||||
def _render_table(self, key: Key, table: Table, prefix: str | None = None) -> str:
|
||||
cur = ""
|
||||
|
||||
if table.display_name is not None:
|
||||
_key = table.display_name
|
||||
else:
|
||||
_key = key.as_string()
|
||||
|
||||
if prefix is not None:
|
||||
_key = prefix + "." + _key
|
||||
|
||||
if not table.is_super_table() or (
|
||||
any(
|
||||
not isinstance(v, (Table, AoT, Whitespace, Null))
|
||||
for _, v in table.value.body
|
||||
)
|
||||
and not key.is_dotted()
|
||||
):
|
||||
open_, close = "[", "]"
|
||||
if table.is_aot_element():
|
||||
open_, close = "[[", "]]"
|
||||
|
||||
newline_in_table_trivia = (
|
||||
"\n" if "\n" not in table.trivia.trail and len(table.value) > 0 else ""
|
||||
)
|
||||
cur += (
|
||||
f"{table.trivia.indent}"
|
||||
f"{open_}"
|
||||
f"{decode(_key)}"
|
||||
f"{close}"
|
||||
f"{table.trivia.comment_ws}"
|
||||
f"{decode(table.trivia.comment)}"
|
||||
f"{table.trivia.trail}"
|
||||
f"{newline_in_table_trivia}"
|
||||
)
|
||||
elif table.trivia.indent == "\n":
|
||||
cur += table.trivia.indent
|
||||
|
||||
for k, v in table.value.body:
|
||||
if isinstance(v, Table):
|
||||
if v.is_super_table():
|
||||
if k.is_dotted() and not key.is_dotted():
|
||||
# Dotted key inside table
|
||||
cur += self._render_table(k, v)
|
||||
else:
|
||||
cur += self._render_table(k, v, prefix=_key)
|
||||
else:
|
||||
cur += self._render_table(k, v, prefix=_key)
|
||||
elif isinstance(v, AoT):
|
||||
cur += self._render_aot(k, v, prefix=_key)
|
||||
else:
|
||||
cur += self._render_simple_item(
|
||||
k, v, prefix=_key if key.is_dotted() else None
|
||||
)
|
||||
|
||||
return cur
|
||||
|
||||
def _render_aot(self, key, aot, prefix=None):
|
||||
_key = key.as_string()
|
||||
if prefix is not None:
|
||||
_key = prefix + "." + _key
|
||||
|
||||
cur = ""
|
||||
_key = decode(_key)
|
||||
for table in aot.body:
|
||||
cur += self._render_aot_table(table, prefix=_key)
|
||||
|
||||
return cur
|
||||
|
||||
def _render_aot_table(self, table: Table, prefix: str | None = None) -> str:
|
||||
cur = ""
|
||||
_key = prefix or ""
|
||||
open_, close = "[[", "]]"
|
||||
|
||||
cur += (
|
||||
f"{table.trivia.indent}"
|
||||
f"{open_}"
|
||||
f"{decode(_key)}"
|
||||
f"{close}"
|
||||
f"{table.trivia.comment_ws}"
|
||||
f"{decode(table.trivia.comment)}"
|
||||
f"{table.trivia.trail}"
|
||||
)
|
||||
|
||||
for k, v in table.value.body:
|
||||
if isinstance(v, Table):
|
||||
if v.is_super_table():
|
||||
if k.is_dotted():
|
||||
# Dotted key inside table
|
||||
cur += self._render_table(k, v)
|
||||
else:
|
||||
cur += self._render_table(k, v, prefix=_key)
|
||||
else:
|
||||
cur += self._render_table(k, v, prefix=_key)
|
||||
elif isinstance(v, AoT):
|
||||
cur += self._render_aot(k, v, prefix=_key)
|
||||
else:
|
||||
cur += self._render_simple_item(k, v)
|
||||
|
||||
return cur
|
||||
|
||||
def _render_simple_item(self, key, item, prefix=None):
|
||||
if key is None:
|
||||
return item.as_string()
|
||||
|
||||
_key = key.as_string()
|
||||
if prefix is not None:
|
||||
_key = prefix + "." + _key
|
||||
|
||||
return (
|
||||
f"{item.trivia.indent}"
|
||||
f"{decode(_key)}"
|
||||
f"{key.sep}"
|
||||
f"{decode(item.as_string())}"
|
||||
f"{item.trivia.comment_ws}"
|
||||
f"{decode(item.trivia.comment)}"
|
||||
f"{item.trivia.trail}"
|
||||
)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return dict.__len__(self)
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(dict.keys(self))
|
||||
|
||||
# Dictionary methods
|
||||
def __getitem__(self, key: Key | str) -> Item | Container:
|
||||
if not isinstance(key, Key):
|
||||
key = SingleKey(key)
|
||||
|
||||
idx = self._map.get(key)
|
||||
if idx is None:
|
||||
raise NonExistentKey(key)
|
||||
|
||||
if isinstance(idx, tuple):
|
||||
# The item we are getting is an out of order table
|
||||
# so we need a proxy to retrieve the proper objects
|
||||
# from the parent container
|
||||
return OutOfOrderTableProxy(self, idx)
|
||||
|
||||
item = self._body[idx][1]
|
||||
if item.is_boolean():
|
||||
return item.value
|
||||
|
||||
return item
|
||||
|
||||
def __setitem__(self, key: Key | str, value: Any) -> None:
|
||||
if key is not None and key in self:
|
||||
old_key = next(filter(lambda k: k == key, self._map))
|
||||
self._replace(old_key, key, value)
|
||||
else:
|
||||
self.append(key, value)
|
||||
|
||||
def __delitem__(self, key: Key | str) -> None:
|
||||
self.remove(key)
|
||||
|
||||
def setdefault(self, key: Key | str, default: Any) -> Any:
|
||||
super().setdefault(key, default=default)
|
||||
return self[key]
|
||||
|
||||
def _replace(self, key: Key | str, new_key: Key | str, value: Item) -> None:
|
||||
if not isinstance(key, Key):
|
||||
key = SingleKey(key)
|
||||
|
||||
idx = self._map.get(key)
|
||||
if idx is None:
|
||||
raise NonExistentKey(key)
|
||||
|
||||
self._replace_at(idx, new_key, value)
|
||||
|
||||
def _replace_at(
|
||||
self, idx: int | tuple[int], new_key: Key | str, value: Item
|
||||
) -> None:
|
||||
value = _item(value)
|
||||
|
||||
if isinstance(idx, tuple):
|
||||
for i in idx[1:]:
|
||||
self._body[i] = (None, Null())
|
||||
|
||||
idx = idx[0]
|
||||
|
||||
k, v = self._body[idx]
|
||||
if not isinstance(new_key, Key):
|
||||
if (
|
||||
isinstance(value, (AoT, Table)) != isinstance(v, (AoT, Table))
|
||||
or new_key != k.key
|
||||
):
|
||||
new_key = SingleKey(new_key)
|
||||
else: # Inherit the sep of the old key
|
||||
new_key = k
|
||||
|
||||
del self._map[k]
|
||||
self._map[new_key] = idx
|
||||
if new_key != k:
|
||||
dict.__delitem__(self, k)
|
||||
|
||||
if isinstance(value, (AoT, Table)) != isinstance(v, (AoT, Table)):
|
||||
# new tables should appear after all non-table values
|
||||
self.remove(k)
|
||||
for i in range(idx, len(self._body)):
|
||||
if isinstance(self._body[i][1], (AoT, Table)):
|
||||
self._insert_at(i, new_key, value)
|
||||
idx = i
|
||||
break
|
||||
else:
|
||||
idx = -1
|
||||
self.append(new_key, value)
|
||||
else:
|
||||
# Copying trivia
|
||||
if not isinstance(value, (Whitespace, AoT)):
|
||||
value.trivia.indent = v.trivia.indent
|
||||
value.trivia.comment_ws = value.trivia.comment_ws or v.trivia.comment_ws
|
||||
value.trivia.comment = value.trivia.comment or v.trivia.comment
|
||||
value.trivia.trail = v.trivia.trail
|
||||
self._body[idx] = (new_key, value)
|
||||
|
||||
if hasattr(value, "invalidate_display_name"):
|
||||
value.invalidate_display_name() # type: ignore[attr-defined]
|
||||
|
||||
if isinstance(value, Table):
|
||||
# Insert a cosmetic new line for tables if:
|
||||
# - it does not have it yet OR is not followed by one
|
||||
# - it is not the last item
|
||||
last, _ = self._previous_item_with_index()
|
||||
idx = last if idx < 0 else idx
|
||||
has_ws = ends_with_whitespace(value)
|
||||
next_ws = idx < last and isinstance(self._body[idx + 1][1], Whitespace)
|
||||
if idx < last and not (next_ws or has_ws):
|
||||
value.append(None, Whitespace("\n"))
|
||||
|
||||
dict.__setitem__(self, new_key.key, value.value)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return repr(self.value)
|
||||
|
||||
def __eq__(self, other: dict) -> bool:
|
||||
if not isinstance(other, dict):
|
||||
return NotImplemented
|
||||
|
||||
return self.value == other
|
||||
|
||||
def _getstate(self, protocol):
|
||||
return (self._parsed,)
|
||||
|
||||
def __reduce__(self):
|
||||
return self.__reduce_ex__(2)
|
||||
|
||||
def __reduce_ex__(self, protocol):
|
||||
return (
|
||||
self.__class__,
|
||||
self._getstate(protocol),
|
||||
(self._map, self._body, self._parsed, self._table_keys),
|
||||
)
|
||||
|
||||
def __setstate__(self, state):
|
||||
self._map = state[0]
|
||||
self._body = state[1]
|
||||
self._parsed = state[2]
|
||||
self._table_keys = state[3]
|
||||
|
||||
for key, item in self._body:
|
||||
if key is not None:
|
||||
dict.__setitem__(self, key.key, item.value)
|
||||
|
||||
def copy(self) -> Container:
|
||||
return copy.copy(self)
|
||||
|
||||
def __copy__(self) -> Container:
|
||||
c = self.__class__(self._parsed)
|
||||
for k, v in dict.items(self):
|
||||
dict.__setitem__(c, k, v)
|
||||
|
||||
c._body += self.body
|
||||
c._map.update(self._map)
|
||||
|
||||
return c
|
||||
|
||||
def _previous_item_with_index(
|
||||
self, idx: int | None = None, ignore=(Null,)
|
||||
) -> tuple[int, Item] | None:
|
||||
"""Find the immediate previous item before index ``idx``"""
|
||||
if idx is None or idx > len(self._body):
|
||||
idx = len(self._body)
|
||||
for i in range(idx - 1, -1, -1):
|
||||
v = self._body[i][-1]
|
||||
if not isinstance(v, ignore):
|
||||
return i, v
|
||||
return None
|
||||
|
||||
def _previous_item(self, idx: int | None = None, ignore=(Null,)) -> Item | None:
|
||||
"""Find the immediate previous item before index ``idx``.
|
||||
If ``idx`` is not given, the last item is returned.
|
||||
"""
|
||||
prev = self._previous_item_with_index(idx, ignore)
|
||||
return prev[-1] if prev else None
|
||||
|
||||
|
||||
class OutOfOrderTableProxy(_CustomDict):
|
||||
def __init__(self, container: Container, indices: tuple[int]) -> None:
|
||||
self._container = container
|
||||
self._internal_container = Container(True)
|
||||
self._tables = []
|
||||
self._tables_map = {}
|
||||
|
||||
for i in indices:
|
||||
_, item = self._container._body[i]
|
||||
|
||||
if isinstance(item, Table):
|
||||
self._tables.append(item)
|
||||
table_idx = len(self._tables) - 1
|
||||
for k, v in item.value.body:
|
||||
self._internal_container.append(k, v)
|
||||
self._tables_map[k] = table_idx
|
||||
if k is not None:
|
||||
dict.__setitem__(self, k.key, v)
|
||||
|
||||
def unwrap(self) -> str:
|
||||
return self._internal_container.unwrap()
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return self._internal_container.value
|
||||
|
||||
def __getitem__(self, key: Key | str) -> Any:
|
||||
if key not in self._internal_container:
|
||||
raise NonExistentKey(key)
|
||||
|
||||
return self._internal_container[key]
|
||||
|
||||
def __setitem__(self, key: Key | str, item: Any) -> None:
|
||||
if key in self._tables_map:
|
||||
table = self._tables[self._tables_map[key]]
|
||||
table[key] = item
|
||||
elif self._tables:
|
||||
table = self._tables[0]
|
||||
table[key] = item
|
||||
else:
|
||||
self._container[key] = item
|
||||
|
||||
self._internal_container[key] = item
|
||||
if key is not None:
|
||||
dict.__setitem__(self, key, item)
|
||||
|
||||
def _remove_table(self, table: Table) -> None:
|
||||
"""Remove table from the parent container"""
|
||||
self._tables.remove(table)
|
||||
for idx, item in enumerate(self._container._body):
|
||||
if item[1] is table:
|
||||
self._container._remove_at(idx)
|
||||
break
|
||||
|
||||
def __delitem__(self, key: Key | str) -> None:
|
||||
if key in self._tables_map:
|
||||
table = self._tables[self._tables_map[key]]
|
||||
del table[key]
|
||||
if not table and len(self._tables) > 1:
|
||||
self._remove_table(table)
|
||||
del self._tables_map[key]
|
||||
else:
|
||||
raise NonExistentKey(key)
|
||||
|
||||
del self._internal_container[key]
|
||||
if key is not None:
|
||||
dict.__delitem__(self, key)
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(dict.keys(self))
|
||||
|
||||
def __len__(self) -> int:
|
||||
return dict.__len__(self)
|
||||
|
||||
def setdefault(self, key: Key | str, default: Any) -> Any:
|
||||
super().setdefault(key, default=default)
|
||||
return self[key]
|
||||
|
||||
|
||||
def ends_with_whitespace(it: Any) -> bool:
|
||||
"""Returns ``True`` if the given item ``it`` is a ``Table`` or ``AoT`` object
|
||||
ending with a ``Whitespace``.
|
||||
"""
|
||||
return (
|
||||
isinstance(it, Table) and isinstance(it.value._previous_item(), Whitespace)
|
||||
) or (isinstance(it, AoT) and len(it) > 0 and isinstance(it[-1], Whitespace))
|
|
@ -0,0 +1,227 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Collection
|
||||
|
||||
|
||||
class TOMLKitError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ParseError(ValueError, TOMLKitError):
|
||||
"""
|
||||
This error occurs when the parser encounters a syntax error
|
||||
in the TOML being parsed. The error references the line and
|
||||
location within the line where the error was encountered.
|
||||
"""
|
||||
|
||||
def __init__(self, line: int, col: int, message: str | None = None) -> None:
|
||||
self._line = line
|
||||
self._col = col
|
||||
|
||||
if message is None:
|
||||
message = "TOML parse error"
|
||||
|
||||
super().__init__(f"{message} at line {self._line} col {self._col}")
|
||||
|
||||
@property
|
||||
def line(self):
|
||||
return self._line
|
||||
|
||||
@property
|
||||
def col(self):
|
||||
return self._col
|
||||
|
||||
|
||||
class MixedArrayTypesError(ParseError):
|
||||
"""
|
||||
An array was found that had two or more element types.
|
||||
"""
|
||||
|
||||
def __init__(self, line: int, col: int) -> None:
|
||||
message = "Mixed types found in array"
|
||||
|
||||
super().__init__(line, col, message=message)
|
||||
|
||||
|
||||
class InvalidNumberError(ParseError):
|
||||
"""
|
||||
A numeric field was improperly specified.
|
||||
"""
|
||||
|
||||
def __init__(self, line: int, col: int) -> None:
|
||||
message = "Invalid number"
|
||||
|
||||
super().__init__(line, col, message=message)
|
||||
|
||||
|
||||
class InvalidDateTimeError(ParseError):
|
||||
"""
|
||||
A datetime field was improperly specified.
|
||||
"""
|
||||
|
||||
def __init__(self, line: int, col: int) -> None:
|
||||
message = "Invalid datetime"
|
||||
|
||||
super().__init__(line, col, message=message)
|
||||
|
||||
|
||||
class InvalidDateError(ParseError):
|
||||
"""
|
||||
A date field was improperly specified.
|
||||
"""
|
||||
|
||||
def __init__(self, line: int, col: int) -> None:
|
||||
message = "Invalid date"
|
||||
|
||||
super().__init__(line, col, message=message)
|
||||
|
||||
|
||||
class InvalidTimeError(ParseError):
|
||||
"""
|
||||
A date field was improperly specified.
|
||||
"""
|
||||
|
||||
def __init__(self, line: int, col: int) -> None:
|
||||
message = "Invalid time"
|
||||
|
||||
super().__init__(line, col, message=message)
|
||||
|
||||
|
||||
class InvalidNumberOrDateError(ParseError):
|
||||
"""
|
||||
A numeric or date field was improperly specified.
|
||||
"""
|
||||
|
||||
def __init__(self, line: int, col: int) -> None:
|
||||
message = "Invalid number or date format"
|
||||
|
||||
super().__init__(line, col, message=message)
|
||||
|
||||
|
||||
class InvalidUnicodeValueError(ParseError):
|
||||
"""
|
||||
A unicode code was improperly specified.
|
||||
"""
|
||||
|
||||
def __init__(self, line: int, col: int) -> None:
|
||||
message = "Invalid unicode value"
|
||||
|
||||
super().__init__(line, col, message=message)
|
||||
|
||||
|
||||
class UnexpectedCharError(ParseError):
|
||||
"""
|
||||
An unexpected character was found during parsing.
|
||||
"""
|
||||
|
||||
def __init__(self, line: int, col: int, char: str) -> None:
|
||||
message = f"Unexpected character: {repr(char)}"
|
||||
|
||||
super().__init__(line, col, message=message)
|
||||
|
||||
|
||||
class EmptyKeyError(ParseError):
|
||||
"""
|
||||
An empty key was found during parsing.
|
||||
"""
|
||||
|
||||
def __init__(self, line: int, col: int) -> None:
|
||||
message = "Empty key"
|
||||
|
||||
super().__init__(line, col, message=message)
|
||||
|
||||
|
||||
class EmptyTableNameError(ParseError):
|
||||
"""
|
||||
An empty table name was found during parsing.
|
||||
"""
|
||||
|
||||
def __init__(self, line: int, col: int) -> None:
|
||||
message = "Empty table name"
|
||||
|
||||
super().__init__(line, col, message=message)
|
||||
|
||||
|
||||
class InvalidCharInStringError(ParseError):
|
||||
"""
|
||||
The string being parsed contains an invalid character.
|
||||
"""
|
||||
|
||||
def __init__(self, line: int, col: int, char: str) -> None:
|
||||
message = f"Invalid character {repr(char)} in string"
|
||||
|
||||
super().__init__(line, col, message=message)
|
||||
|
||||
|
||||
class UnexpectedEofError(ParseError):
|
||||
"""
|
||||
The TOML being parsed ended before the end of a statement.
|
||||
"""
|
||||
|
||||
def __init__(self, line: int, col: int) -> None:
|
||||
message = "Unexpected end of file"
|
||||
|
||||
super().__init__(line, col, message=message)
|
||||
|
||||
|
||||
class InternalParserError(ParseError):
|
||||
"""
|
||||
An error that indicates a bug in the parser.
|
||||
"""
|
||||
|
||||
def __init__(self, line: int, col: int, message: str | None = None) -> None:
|
||||
msg = "Internal parser error"
|
||||
if message:
|
||||
msg += f" ({message})"
|
||||
|
||||
super().__init__(line, col, message=msg)
|
||||
|
||||
|
||||
class NonExistentKey(KeyError, TOMLKitError):
|
||||
"""
|
||||
A non-existent key was used.
|
||||
"""
|
||||
|
||||
def __init__(self, key):
|
||||
message = f'Key "{key}" does not exist.'
|
||||
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class KeyAlreadyPresent(TOMLKitError):
|
||||
"""
|
||||
An already present key was used.
|
||||
"""
|
||||
|
||||
def __init__(self, key):
|
||||
key = getattr(key, "key", key)
|
||||
message = f'Key "{key}" already exists.'
|
||||
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class InvalidControlChar(ParseError):
|
||||
def __init__(self, line: int, col: int, char: int, type: str) -> None:
|
||||
display_code = "\\u00"
|
||||
|
||||
if char < 16:
|
||||
display_code += "0"
|
||||
|
||||
display_code += hex(char)[2:]
|
||||
|
||||
message = (
|
||||
"Control characters (codes less than 0x1f and 0x7f)"
|
||||
f" are not allowed in {type}, "
|
||||
f"use {display_code} instead"
|
||||
)
|
||||
|
||||
super().__init__(line, col, message=message)
|
||||
|
||||
|
||||
class InvalidStringError(ValueError, TOMLKitError):
|
||||
def __init__(self, value: str, invalid_sequences: Collection[str], delimiter: str):
|
||||
repr_ = repr(value)[1:-1]
|
||||
super().__init__(
|
||||
f"Invalid string: {delimiter}{repr_}{delimiter}. "
|
||||
f"The character sequences {invalid_sequences} are invalid."
|
||||
)
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -0,0 +1,180 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from copy import copy
|
||||
from typing import Any
|
||||
|
||||
from tomlkit.exceptions import ParseError
|
||||
from tomlkit.exceptions import UnexpectedCharError
|
||||
from tomlkit.toml_char import TOMLChar
|
||||
|
||||
|
||||
class _State:
|
||||
def __init__(
|
||||
self,
|
||||
source: Source,
|
||||
save_marker: bool | None = False,
|
||||
restore: bool | None = False,
|
||||
) -> None:
|
||||
self._source = source
|
||||
self._save_marker = save_marker
|
||||
self.restore = restore
|
||||
|
||||
def __enter__(self) -> _State:
|
||||
# Entering this context manager - save the state
|
||||
self._chars = copy(self._source._chars)
|
||||
self._idx = self._source._idx
|
||||
self._current = self._source._current
|
||||
self._marker = self._source._marker
|
||||
|
||||
return self
|
||||
|
||||
def __exit__(self, exception_type, exception_val, trace):
|
||||
# Exiting this context manager - restore the prior state
|
||||
if self.restore or exception_type:
|
||||
self._source._chars = self._chars
|
||||
self._source._idx = self._idx
|
||||
self._source._current = self._current
|
||||
if self._save_marker:
|
||||
self._source._marker = self._marker
|
||||
|
||||
|
||||
class _StateHandler:
|
||||
"""
|
||||
State preserver for the Parser.
|
||||
"""
|
||||
|
||||
def __init__(self, source: Source) -> None:
|
||||
self._source = source
|
||||
self._states = []
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return _State(self._source, *args, **kwargs)
|
||||
|
||||
def __enter__(self) -> None:
|
||||
state = self()
|
||||
self._states.append(state)
|
||||
return state.__enter__()
|
||||
|
||||
def __exit__(self, exception_type, exception_val, trace):
|
||||
state = self._states.pop()
|
||||
return state.__exit__(exception_type, exception_val, trace)
|
||||
|
||||
|
||||
class Source(str):
|
||||
EOF = TOMLChar("\0")
|
||||
|
||||
def __init__(self, _: str) -> None:
|
||||
super().__init__()
|
||||
|
||||
# Collection of TOMLChars
|
||||
self._chars = iter([(i, TOMLChar(c)) for i, c in enumerate(self)])
|
||||
|
||||
self._idx = 0
|
||||
self._marker = 0
|
||||
self._current = TOMLChar("")
|
||||
|
||||
self._state = _StateHandler(self)
|
||||
|
||||
self.inc()
|
||||
|
||||
def reset(self):
|
||||
# initialize both idx and current
|
||||
self.inc()
|
||||
|
||||
# reset marker
|
||||
self.mark()
|
||||
|
||||
@property
|
||||
def state(self) -> _StateHandler:
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def idx(self) -> int:
|
||||
return self._idx
|
||||
|
||||
@property
|
||||
def current(self) -> TOMLChar:
|
||||
return self._current
|
||||
|
||||
@property
|
||||
def marker(self) -> int:
|
||||
return self._marker
|
||||
|
||||
def extract(self) -> str:
|
||||
"""
|
||||
Extracts the value between marker and index
|
||||
"""
|
||||
return self[self._marker : self._idx]
|
||||
|
||||
def inc(self, exception: type[ParseError] | None = None) -> bool:
|
||||
"""
|
||||
Increments the parser if the end of the input has not been reached.
|
||||
Returns whether or not it was able to advance.
|
||||
"""
|
||||
try:
|
||||
self._idx, self._current = next(self._chars)
|
||||
|
||||
return True
|
||||
except StopIteration:
|
||||
self._idx = len(self)
|
||||
self._current = self.EOF
|
||||
if exception:
|
||||
raise self.parse_error(exception)
|
||||
|
||||
return False
|
||||
|
||||
def inc_n(self, n: int, exception: type[ParseError] | None = None) -> bool:
|
||||
"""
|
||||
Increments the parser by n characters
|
||||
if the end of the input has not been reached.
|
||||
"""
|
||||
return all(self.inc(exception=exception) for _ in range(n))
|
||||
|
||||
def consume(self, chars, min=0, max=-1):
|
||||
"""
|
||||
Consume chars until min/max is satisfied is valid.
|
||||
"""
|
||||
while self.current in chars and max != 0:
|
||||
min -= 1
|
||||
max -= 1
|
||||
if not self.inc():
|
||||
break
|
||||
|
||||
# failed to consume minimum number of characters
|
||||
if min > 0:
|
||||
raise self.parse_error(UnexpectedCharError, self.current)
|
||||
|
||||
def end(self) -> bool:
|
||||
"""
|
||||
Returns True if the parser has reached the end of the input.
|
||||
"""
|
||||
return self._current is self.EOF
|
||||
|
||||
def mark(self) -> None:
|
||||
"""
|
||||
Sets the marker to the index's current position
|
||||
"""
|
||||
self._marker = self._idx
|
||||
|
||||
def parse_error(
|
||||
self,
|
||||
exception: type[ParseError] = ParseError,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> ParseError:
|
||||
"""
|
||||
Creates a generic "parse error" at the current position.
|
||||
"""
|
||||
line, col = self._to_linecol()
|
||||
|
||||
return exception(line, col, *args, **kwargs)
|
||||
|
||||
def _to_linecol(self) -> tuple[int, int]:
|
||||
cur = 0
|
||||
for i, line in enumerate(self.splitlines()):
|
||||
if cur + len(line) + 1 > self.idx:
|
||||
return (i + 1, self.idx - cur)
|
||||
|
||||
cur += len(line) + 1
|
||||
|
||||
return len(self.splitlines()), 0
|
|
@ -0,0 +1,52 @@
|
|||
import string
|
||||
|
||||
|
||||
class TOMLChar(str):
|
||||
def __init__(self, c):
|
||||
super().__init__()
|
||||
|
||||
if len(self) > 1:
|
||||
raise ValueError("A TOML character must be of length 1")
|
||||
|
||||
BARE = string.ascii_letters + string.digits + "-_"
|
||||
KV = "= \t"
|
||||
NUMBER = string.digits + "+-_.e"
|
||||
SPACES = " \t"
|
||||
NL = "\n\r"
|
||||
WS = SPACES + NL
|
||||
|
||||
def is_bare_key_char(self) -> bool:
|
||||
"""
|
||||
Whether the character is a valid bare key name or not.
|
||||
"""
|
||||
return self in self.BARE
|
||||
|
||||
def is_kv_sep(self) -> bool:
|
||||
"""
|
||||
Whether the character is a valid key/value separator or not.
|
||||
"""
|
||||
return self in self.KV
|
||||
|
||||
def is_int_float_char(self) -> bool:
|
||||
"""
|
||||
Whether the character if a valid integer or float value character or not.
|
||||
"""
|
||||
return self in self.NUMBER
|
||||
|
||||
def is_ws(self) -> bool:
|
||||
"""
|
||||
Whether the character is a whitespace character or not.
|
||||
"""
|
||||
return self in self.WS
|
||||
|
||||
def is_nl(self) -> bool:
|
||||
"""
|
||||
Whether the character is a new line character or not.
|
||||
"""
|
||||
return self in self.NL
|
||||
|
||||
def is_spaces(self) -> bool:
|
||||
"""
|
||||
Whether the character is a space or not
|
||||
"""
|
||||
return self in self.SPACES
|
|
@ -0,0 +1,7 @@
|
|||
from tomlkit.container import Container
|
||||
|
||||
|
||||
class TOMLDocument(Container):
|
||||
"""
|
||||
A TOML document.
|
||||
"""
|
|
@ -0,0 +1,58 @@
|
|||
import os
|
||||
import re
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from tomlkit.api import loads
|
||||
from tomlkit.toml_document import TOMLDocument
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from _typeshed import StrPath as _StrPath
|
||||
else:
|
||||
from typing import Union
|
||||
|
||||
_StrPath = Union[str, os.PathLike]
|
||||
|
||||
|
||||
class TOMLFile:
|
||||
"""
|
||||
Represents a TOML file.
|
||||
|
||||
:param path: path to the TOML file
|
||||
"""
|
||||
|
||||
def __init__(self, path: _StrPath) -> None:
|
||||
self._path = path
|
||||
self._linesep = os.linesep
|
||||
|
||||
def read(self) -> TOMLDocument:
|
||||
"""Read the file content as a :class:`tomlkit.toml_document.TOMLDocument`."""
|
||||
with open(self._path, encoding="utf-8", newline="") as f:
|
||||
content = f.read()
|
||||
|
||||
# check if consistent line endings
|
||||
num_newline = content.count("\n")
|
||||
if num_newline > 0:
|
||||
num_win_eol = content.count("\r\n")
|
||||
if num_win_eol == num_newline:
|
||||
self._linesep = "\r\n"
|
||||
elif num_win_eol == 0:
|
||||
self._linesep = "\n"
|
||||
else:
|
||||
self._linesep = "mixed"
|
||||
|
||||
return loads(content)
|
||||
|
||||
def write(self, data: TOMLDocument) -> None:
|
||||
"""Write the TOMLDocument to the file."""
|
||||
content = data.as_string()
|
||||
|
||||
# apply linesep
|
||||
if self._linesep == "\n":
|
||||
content = content.replace("\r\n", "\n")
|
||||
elif self._linesep == "\r\n":
|
||||
content = re.sub(r"(?<!\r)\n", "\r\n", content)
|
||||
|
||||
with open(self._path, "w", encoding="utf-8", newline="") as f:
|
||||
f.write(content)
|
Загрузка…
Ссылка в новой задаче