|
|
3f51ca |
From dda1087a2e6e0f5fcb8623983f5296b8a426cfc5 Mon Sep 17 00:00:00 2001
|
|
|
3f51ca |
From: Christian Heimes <cheimes@redhat.com>
|
|
|
3f51ca |
Date: Fri, 8 Dec 2017 12:38:41 +0100
|
|
|
3f51ca |
Subject: [PATCH] Add safe DirectiveSetter context manager
|
|
|
3f51ca |
|
|
|
3f51ca |
installutils.set_directive() is both inefficient and potentially
|
|
|
3f51ca |
dangerous. It does not ensure that the whole file is written and
|
|
|
3f51ca |
properly synced to disk. In worst case it could lead to partially
|
|
|
3f51ca |
written or destroyed config files.
|
|
|
3f51ca |
|
|
|
3f51ca |
The new DirectiveSetter context manager wraps everything under an easy
|
|
|
3f51ca |
to use interface.
|
|
|
3f51ca |
|
|
|
3f51ca |
https://pagure.io/freeipa/issue/7312
|
|
|
3f51ca |
|
|
|
3f51ca |
Signed-off-by: Christian Heimes <cheimes@redhat.com>
|
|
|
3f51ca |
Reviewed-By: Florence Blanc-Renaud <frenaud@redhat.com>
|
|
|
3f51ca |
---
|
|
|
3f51ca |
ipaserver/install/cainstance.py | 109 ++++++++++-----------
|
|
|
3f51ca |
ipaserver/install/installutils.py | 87 +++++++++++++++-
|
|
|
3f51ca |
.../test_install/test_installutils.py | 67 +++++++++++++
|
|
|
3f51ca |
3 files changed, 205 insertions(+), 58 deletions(-)
|
|
|
3f51ca |
|
|
|
3f51ca |
diff --git a/ipaserver/install/cainstance.py b/ipaserver/install/cainstance.py
|
|
|
3f51ca |
index dd410d1c97cb4b27d35086bb2f511c42c02d022f..20635eae22268ff72de73b8b9c430050114bb45b 100644
|
|
|
3f51ca |
--- a/ipaserver/install/cainstance.py
|
|
|
3f51ca |
+++ b/ipaserver/install/cainstance.py
|
|
|
3f51ca |
@@ -908,62 +908,61 @@ class CAInstance(DogtagInstance):
|
|
|
3f51ca |
https://access.redhat.com/knowledge/docs/en-US/Red_Hat_Certificate_System/8.0/html/Admin_Guide/Setting_up_Publishing.html
|
|
|
3f51ca |
"""
|
|
|
3f51ca |
|
|
|
3f51ca |
- def put(k, v):
|
|
|
3f51ca |
- installutils.set_directive(
|
|
|
3f51ca |
- paths.CA_CS_CFG_PATH, k, v, quotes=False, separator='=')
|
|
|
3f51ca |
+ with installutils.DirectiveSetter(paths.CA_CS_CFG_PATH,
|
|
|
3f51ca |
+ quotes=False, separator='=') as ds:
|
|
|
3f51ca |
|
|
|
3f51ca |
- # Enable file publishing, disable LDAP
|
|
|
3f51ca |
- put('ca.publish.enable', 'true')
|
|
|
3f51ca |
- put('ca.publish.ldappublish.enable', 'false')
|
|
|
3f51ca |
-
|
|
|
3f51ca |
- # Create the file publisher, der only, not b64
|
|
|
3f51ca |
- put('ca.publish.publisher.impl.FileBasedPublisher.class',
|
|
|
3f51ca |
- 'com.netscape.cms.publish.publishers.FileBasedPublisher')
|
|
|
3f51ca |
- put('ca.publish.publisher.instance.FileBaseCRLPublisher.crlLinkExt',
|
|
|
3f51ca |
- 'bin')
|
|
|
3f51ca |
- put('ca.publish.publisher.instance.FileBaseCRLPublisher.directory',
|
|
|
3f51ca |
- self.prepare_crl_publish_dir())
|
|
|
3f51ca |
- put('ca.publish.publisher.instance.FileBaseCRLPublisher.latestCrlLink',
|
|
|
3f51ca |
- 'true')
|
|
|
3f51ca |
- put('ca.publish.publisher.instance.FileBaseCRLPublisher.pluginName',
|
|
|
3f51ca |
- 'FileBasedPublisher')
|
|
|
3f51ca |
- put('ca.publish.publisher.instance.FileBaseCRLPublisher.timeStamp',
|
|
|
3f51ca |
- 'LocalTime')
|
|
|
3f51ca |
- put('ca.publish.publisher.instance.FileBaseCRLPublisher.zipCRLs',
|
|
|
3f51ca |
- 'false')
|
|
|
3f51ca |
- put('ca.publish.publisher.instance.FileBaseCRLPublisher.zipLevel', '9')
|
|
|
3f51ca |
- put('ca.publish.publisher.instance.FileBaseCRLPublisher.Filename.b64',
|
|
|
3f51ca |
- 'false')
|
|
|
3f51ca |
- put('ca.publish.publisher.instance.FileBaseCRLPublisher.Filename.der',
|
|
|
3f51ca |
- 'true')
|
|
|
3f51ca |
-
|
|
|
3f51ca |
- # The publishing rule
|
|
|
3f51ca |
- put('ca.publish.rule.instance.FileCrlRule.enable', 'true')
|
|
|
3f51ca |
- put('ca.publish.rule.instance.FileCrlRule.mapper', 'NoMap')
|
|
|
3f51ca |
- put('ca.publish.rule.instance.FileCrlRule.pluginName', 'Rule')
|
|
|
3f51ca |
- put('ca.publish.rule.instance.FileCrlRule.predicate', '')
|
|
|
3f51ca |
- put('ca.publish.rule.instance.FileCrlRule.publisher',
|
|
|
3f51ca |
- 'FileBaseCRLPublisher')
|
|
|
3f51ca |
- put('ca.publish.rule.instance.FileCrlRule.type', 'crl')
|
|
|
3f51ca |
-
|
|
|
3f51ca |
- # Now disable LDAP publishing
|
|
|
3f51ca |
- put('ca.publish.rule.instance.LdapCaCertRule.enable', 'false')
|
|
|
3f51ca |
- put('ca.publish.rule.instance.LdapCrlRule.enable', 'false')
|
|
|
3f51ca |
- put('ca.publish.rule.instance.LdapUserCertRule.enable', 'false')
|
|
|
3f51ca |
- put('ca.publish.rule.instance.LdapXCertRule.enable', 'false')
|
|
|
3f51ca |
-
|
|
|
3f51ca |
- # If we are the initial master then we are the CRL generator, otherwise
|
|
|
3f51ca |
- # we point to that master for CRLs.
|
|
|
3f51ca |
- if not self.clone:
|
|
|
3f51ca |
- # These next two are defaults, but I want to be explicit that the
|
|
|
3f51ca |
- # initial master is the CRL generator.
|
|
|
3f51ca |
- put('ca.crl.MasterCRL.enableCRLCache', 'true')
|
|
|
3f51ca |
- put('ca.crl.MasterCRL.enableCRLUpdates', 'true')
|
|
|
3f51ca |
- put('ca.listenToCloneModifications', 'true')
|
|
|
3f51ca |
- else:
|
|
|
3f51ca |
- put('ca.crl.MasterCRL.enableCRLCache', 'false')
|
|
|
3f51ca |
- put('ca.crl.MasterCRL.enableCRLUpdates', 'false')
|
|
|
3f51ca |
- put('ca.listenToCloneModifications', 'false')
|
|
|
3f51ca |
+ # Enable file publishing, disable LDAP
|
|
|
3f51ca |
+ ds.set('ca.publish.enable', 'true')
|
|
|
3f51ca |
+ ds.set('ca.publish.ldappublish.enable', 'false')
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ # Create the file publisher, der only, not b64
|
|
|
3f51ca |
+ ds.set(
|
|
|
3f51ca |
+ 'ca.publish.publisher.impl.FileBasedPublisher.class',
|
|
|
3f51ca |
+ 'com.netscape.cms.publish.publishers.FileBasedPublisher'
|
|
|
3f51ca |
+ )
|
|
|
3f51ca |
+ prefix = 'ca.publish.publisher.instance.FileBaseCRLPublisher.'
|
|
|
3f51ca |
+ ds.set(prefix + 'crlLinkExt', 'bin')
|
|
|
3f51ca |
+ ds.set(prefix + 'directory', self.prepare_crl_publish_dir())
|
|
|
3f51ca |
+ ds.set(prefix + 'latestCrlLink', 'true')
|
|
|
3f51ca |
+ ds.set(prefix + 'pluginName', 'FileBasedPublisher')
|
|
|
3f51ca |
+ ds.set(prefix + 'timeStamp', 'LocalTime')
|
|
|
3f51ca |
+ ds.set(prefix + 'zipCRLs', 'false')
|
|
|
3f51ca |
+ ds.set(prefix + 'zipLevel', '9')
|
|
|
3f51ca |
+ ds.set(prefix + 'Filename.b64', 'false')
|
|
|
3f51ca |
+ ds.set(prefix + 'Filename.der', 'true')
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ # The publishing rule
|
|
|
3f51ca |
+ ds.set('ca.publish.rule.instance.FileCrlRule.enable', 'true')
|
|
|
3f51ca |
+ ds.set('ca.publish.rule.instance.FileCrlRule.mapper', 'NoMap')
|
|
|
3f51ca |
+ ds.set('ca.publish.rule.instance.FileCrlRule.pluginName', 'Rule')
|
|
|
3f51ca |
+ ds.set('ca.publish.rule.instance.FileCrlRule.predicate', '')
|
|
|
3f51ca |
+ ds.set(
|
|
|
3f51ca |
+ 'ca.publish.rule.instance.FileCrlRule.publisher',
|
|
|
3f51ca |
+ 'FileBaseCRLPublisher'
|
|
|
3f51ca |
+ )
|
|
|
3f51ca |
+ ds.set('ca.publish.rule.instance.FileCrlRule.type', 'crl')
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ # Now disable LDAP publishing
|
|
|
3f51ca |
+ ds.set('ca.publish.rule.instance.LdapCaCertRule.enable', 'false')
|
|
|
3f51ca |
+ ds.set('ca.publish.rule.instance.LdapCrlRule.enable', 'false')
|
|
|
3f51ca |
+ ds.set(
|
|
|
3f51ca |
+ 'ca.publish.rule.instance.LdapUserCertRule.enable',
|
|
|
3f51ca |
+ 'false'
|
|
|
3f51ca |
+ )
|
|
|
3f51ca |
+ ds.set('ca.publish.rule.instance.LdapXCertRule.enable', 'false')
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ # If we are the initial master then we are the CRL generator,
|
|
|
3f51ca |
+ # otherwise we point to that master for CRLs.
|
|
|
3f51ca |
+ if not self.clone:
|
|
|
3f51ca |
+ # These next two are defaults, but I want to be explicit
|
|
|
3f51ca |
+ # that the initial master is the CRL generator.
|
|
|
3f51ca |
+ ds.set('ca.crl.MasterCRL.enableCRLCache', 'true')
|
|
|
3f51ca |
+ ds.set('ca.crl.MasterCRL.enableCRLUpdates', 'true')
|
|
|
3f51ca |
+ ds.set('ca.listenToCloneModifications', 'true')
|
|
|
3f51ca |
+ else:
|
|
|
3f51ca |
+ ds.set('ca.crl.MasterCRL.enableCRLCache', 'false')
|
|
|
3f51ca |
+ ds.set('ca.crl.MasterCRL.enableCRLUpdates', 'false')
|
|
|
3f51ca |
+ ds.set('ca.listenToCloneModifications', 'false')
|
|
|
3f51ca |
|
|
|
3f51ca |
def uninstall(self):
|
|
|
3f51ca |
# just eat state
|
|
|
3f51ca |
diff --git a/ipaserver/install/installutils.py b/ipaserver/install/installutils.py
|
|
|
3f51ca |
index b56cf591496c679e5fcf3e94f458c286216eb1e4..08e098f8656c8fe61a87fb046c01e1487c25da7f 100644
|
|
|
3f51ca |
--- a/ipaserver/install/installutils.py
|
|
|
3f51ca |
+++ b/ipaserver/install/installutils.py
|
|
|
3f51ca |
@@ -24,6 +24,7 @@ import errno
|
|
|
3f51ca |
import socket
|
|
|
3f51ca |
import getpass
|
|
|
3f51ca |
import gssapi
|
|
|
3f51ca |
+import io
|
|
|
3f51ca |
import ldif
|
|
|
3f51ca |
import os
|
|
|
3f51ca |
import re
|
|
|
3f51ca |
@@ -31,6 +32,7 @@ import fileinput
|
|
|
3f51ca |
import sys
|
|
|
3f51ca |
import tempfile
|
|
|
3f51ca |
import shutil
|
|
|
3f51ca |
+import stat # pylint: disable=bad-python3-import
|
|
|
3f51ca |
import traceback
|
|
|
3f51ca |
import textwrap
|
|
|
3f51ca |
from contextlib import contextmanager
|
|
|
3f51ca |
@@ -428,6 +430,82 @@ def unquote_directive_value(value, quote_char):
|
|
|
3f51ca |
return unescaped_value
|
|
|
3f51ca |
|
|
|
3f51ca |
|
|
|
3f51ca |
+_SENTINEL = object()
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+class DirectiveSetter(object):
|
|
|
3f51ca |
+ """Safe directive setter
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ with DirectiveSetter('/path/to/conf') as ds:
|
|
|
3f51ca |
+ ds.set(key, value)
|
|
|
3f51ca |
+ """
|
|
|
3f51ca |
+ def __init__(self, filename, quotes=True, separator=' '):
|
|
|
3f51ca |
+ self.filename = os.path.abspath(filename)
|
|
|
3f51ca |
+ self.quotes = quotes
|
|
|
3f51ca |
+ self.separator = separator
|
|
|
3f51ca |
+ self.lines = None
|
|
|
3f51ca |
+ self.stat = None
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ def __enter__(self):
|
|
|
3f51ca |
+ with io.open(self.filename) as f:
|
|
|
3f51ca |
+ self.stat = os.fstat(f.fileno())
|
|
|
3f51ca |
+ self.lines = list(f)
|
|
|
3f51ca |
+ return self
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ def __exit__(self, exc_type, exc_val, exc_tb):
|
|
|
3f51ca |
+ if exc_type is not None:
|
|
|
3f51ca |
+ # something went wrong, reset
|
|
|
3f51ca |
+ self.lines = None
|
|
|
3f51ca |
+ self.stat = None
|
|
|
3f51ca |
+ return
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ directory, prefix = os.path.split(self.filename)
|
|
|
3f51ca |
+ # use tempfile in same directory to have atomic rename
|
|
|
3f51ca |
+ fd, name = tempfile.mkstemp(prefix=prefix, dir=directory, text=True)
|
|
|
3f51ca |
+ with io.open(fd, mode='w', closefd=True) as f:
|
|
|
3f51ca |
+ for line in self.lines:
|
|
|
3f51ca |
+ if not isinstance(line, six.text_type):
|
|
|
3f51ca |
+ line = line.decode('utf-8')
|
|
|
3f51ca |
+ f.write(line)
|
|
|
3f51ca |
+ self.lines = None
|
|
|
3f51ca |
+ os.fchmod(f.fileno(), stat.S_IMODE(self.stat.st_mode))
|
|
|
3f51ca |
+ os.fchown(f.fileno(), self.stat.st_uid, self.stat.st_gid)
|
|
|
3f51ca |
+ self.stat = None
|
|
|
3f51ca |
+ # flush and sync tempfile inode
|
|
|
3f51ca |
+ f.flush()
|
|
|
3f51ca |
+ os.fsync(f.fileno())
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ # rename file and sync directory inode
|
|
|
3f51ca |
+ os.rename(name, self.filename)
|
|
|
3f51ca |
+ dirfd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
|
|
|
3f51ca |
+ try:
|
|
|
3f51ca |
+ os.fsync(dirfd)
|
|
|
3f51ca |
+ finally:
|
|
|
3f51ca |
+ os.close(dirfd)
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ def set(self, directive, value, quotes=_SENTINEL, separator=_SENTINEL):
|
|
|
3f51ca |
+ """Set a single directive
|
|
|
3f51ca |
+ """
|
|
|
3f51ca |
+ if quotes is _SENTINEL:
|
|
|
3f51ca |
+ quotes = self.quotes
|
|
|
3f51ca |
+ if separator is _SENTINEL:
|
|
|
3f51ca |
+ separator = self.separator
|
|
|
3f51ca |
+ # materialize lines
|
|
|
3f51ca |
+ # set_directive_lines() modify item, shrink or enlage line count
|
|
|
3f51ca |
+ self.lines = list(set_directive_lines(
|
|
|
3f51ca |
+ quotes, separator, directive, value, self.lines
|
|
|
3f51ca |
+ ))
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ def setitems(self, items):
|
|
|
3f51ca |
+ """Set multiple directives from a dict or list with key/value pairs
|
|
|
3f51ca |
+ """
|
|
|
3f51ca |
+ if isinstance(items, dict):
|
|
|
3f51ca |
+ # dict-like, use sorted for stable order
|
|
|
3f51ca |
+ items = sorted(items.items())
|
|
|
3f51ca |
+ for k, v in items:
|
|
|
3f51ca |
+ self.set(k, v)
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+
|
|
|
3f51ca |
def set_directive(filename, directive, value, quotes=True, separator=' '):
|
|
|
3f51ca |
"""Set a name/value pair directive in a configuration file.
|
|
|
3f51ca |
|
|
|
3f51ca |
@@ -447,8 +525,10 @@ def set_directive(filename, directive, value, quotes=True, separator=' '):
|
|
|
3f51ca |
st = os.stat(filename)
|
|
|
3f51ca |
with open(filename, 'r') as f:
|
|
|
3f51ca |
lines = list(f) # read the whole file
|
|
|
3f51ca |
- new_lines = set_directive_lines(
|
|
|
3f51ca |
- quotes, separator, directive, value, lines)
|
|
|
3f51ca |
+ # materialize new list
|
|
|
3f51ca |
+ new_lines = list(set_directive_lines(
|
|
|
3f51ca |
+ quotes, separator, directive, value, lines
|
|
|
3f51ca |
+ ))
|
|
|
3f51ca |
with open(filename, 'w') as f:
|
|
|
3f51ca |
# don't construct the whole string; write line-wise
|
|
|
3f51ca |
for line in new_lines:
|
|
|
3f51ca |
@@ -472,8 +552,9 @@ def set_directive_lines(quotes, separator, k, v, lines):
|
|
|
3f51ca |
new_line = ''.join([k, separator, v_quoted, '\n'])
|
|
|
3f51ca |
|
|
|
3f51ca |
found = False
|
|
|
3f51ca |
+ matcher = re.compile(r'\s*{}'.format(re.escape(k + separator)))
|
|
|
3f51ca |
for line in lines:
|
|
|
3f51ca |
- if re.match(r'\s*{}'.format(re.escape(k + separator)), line):
|
|
|
3f51ca |
+ if matcher.match(line):
|
|
|
3f51ca |
found = True
|
|
|
3f51ca |
if v is not None:
|
|
|
3f51ca |
yield new_line
|
|
|
3f51ca |
diff --git a/ipatests/test_ipaserver/test_install/test_installutils.py b/ipatests/test_ipaserver/test_install/test_installutils.py
|
|
|
3f51ca |
index cc8fd3cf3f82c2d9af48287f506a566ffbfc39f6..3c992c9ab06ddc3af557329de81debe704b0fb16 100644
|
|
|
3f51ca |
--- a/ipatests/test_ipaserver/test_install/test_installutils.py
|
|
|
3f51ca |
+++ b/ipatests/test_ipaserver/test_install/test_installutils.py
|
|
|
3f51ca |
@@ -3,8 +3,11 @@
|
|
|
3f51ca |
#
|
|
|
3f51ca |
|
|
|
3f51ca |
import os
|
|
|
3f51ca |
+import shutil
|
|
|
3f51ca |
import tempfile
|
|
|
3f51ca |
|
|
|
3f51ca |
+import pytest
|
|
|
3f51ca |
+
|
|
|
3f51ca |
from ipaserver.install import installutils
|
|
|
3f51ca |
|
|
|
3f51ca |
EXAMPLE_CONFIG = [
|
|
|
3f51ca |
@@ -13,6 +16,17 @@ EXAMPLE_CONFIG = [
|
|
|
3f51ca |
]
|
|
|
3f51ca |
|
|
|
3f51ca |
|
|
|
3f51ca |
+@pytest.fixture
|
|
|
3f51ca |
+def tempdir(request):
|
|
|
3f51ca |
+ tempdir = tempfile.mkdtemp()
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ def fin():
|
|
|
3f51ca |
+ shutil.rmtree(tempdir)
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ request.addfinalizer(fin)
|
|
|
3f51ca |
+ return tempdir
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+
|
|
|
3f51ca |
class test_set_directive_lines(object):
|
|
|
3f51ca |
def test_remove_directive(self):
|
|
|
3f51ca |
lines = installutils.set_directive_lines(
|
|
|
3f51ca |
@@ -55,3 +69,56 @@ class test_set_directive(object):
|
|
|
3f51ca |
|
|
|
3f51ca |
finally:
|
|
|
3f51ca |
os.remove(filename)
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+def test_directivesetter(tempdir):
|
|
|
3f51ca |
+ filename = os.path.join(tempdir, 'example.conf')
|
|
|
3f51ca |
+ with open(filename, 'w') as f:
|
|
|
3f51ca |
+ for line in EXAMPLE_CONFIG:
|
|
|
3f51ca |
+ f.write(line)
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ ds = installutils.DirectiveSetter(filename)
|
|
|
3f51ca |
+ assert ds.lines is None
|
|
|
3f51ca |
+ with ds:
|
|
|
3f51ca |
+ assert ds.lines == EXAMPLE_CONFIG
|
|
|
3f51ca |
+ ds.set('foo', '3') # quoted, space separated, doesn't change 'foo='
|
|
|
3f51ca |
+ ds.set('foobar', None, separator='=') # remove
|
|
|
3f51ca |
+ ds.set('baz', '4', False, '=') # add
|
|
|
3f51ca |
+ ds.setitems([
|
|
|
3f51ca |
+ ('list1', 'value1'),
|
|
|
3f51ca |
+ ('list2', 'value2'),
|
|
|
3f51ca |
+ ])
|
|
|
3f51ca |
+ ds.setitems({
|
|
|
3f51ca |
+ 'dict1': 'value1',
|
|
|
3f51ca |
+ 'dict2': 'value2',
|
|
|
3f51ca |
+ })
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ with open(filename, 'r') as f:
|
|
|
3f51ca |
+ lines = list(f)
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ assert lines == [
|
|
|
3f51ca |
+ 'foo=1\n',
|
|
|
3f51ca |
+ 'foo "3"\n',
|
|
|
3f51ca |
+ 'baz=4\n',
|
|
|
3f51ca |
+ 'list1 "value1"\n',
|
|
|
3f51ca |
+ 'list2 "value2"\n',
|
|
|
3f51ca |
+ 'dict1 "value1"\n',
|
|
|
3f51ca |
+ 'dict2 "value2"\n',
|
|
|
3f51ca |
+ ]
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ with installutils.DirectiveSetter(filename, True, '=') as ds:
|
|
|
3f51ca |
+ ds.set('foo', '4') # doesn't change 'foo '
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ with open(filename, 'r') as f:
|
|
|
3f51ca |
+ lines = list(f)
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ assert lines == [
|
|
|
3f51ca |
+ 'foo="4"\n',
|
|
|
3f51ca |
+ 'foo "3"\n',
|
|
|
3f51ca |
+ 'baz=4\n',
|
|
|
3f51ca |
+ 'list1 "value1"\n',
|
|
|
3f51ca |
+ 'list2 "value2"\n',
|
|
|
3f51ca |
+ 'dict1 "value1"\n',
|
|
|
3f51ca |
+ 'dict2 "value2"\n',
|
|
|
3f51ca |
+
|
|
|
3f51ca |
+ ]
|
|
|
3f51ca |
--
|
|
|
3f51ca |
2.13.6
|
|
|
3f51ca |
|