Merge lp:~vila/bzr/832046-globs-store-ordered into lp:bzr

Proposed by Vincent Ladeuil on 2011-12-22
Status: Merged
Approved by: Martin Packman on 2012-01-05
Approved revision: 6411
Merged at revision: 6437
Proposed branch: lp:~vila/bzr/832046-globs-store-ordered
Merge into: lp:bzr
Diff against target: 304 lines (+163/-21) (has conflicts)
4 files modified
bzrlib/config.py (+62/-10)
bzrlib/tests/test_config.py (+87/-8)
doc/developers/configuration.txt (+6/-3)
doc/en/release-notes/bzr-2.5.txt (+8/-0)
Text conflict in doc/en/release-notes/bzr-2.5.txt
To merge this branch: bzr merge lp:~vila/bzr/832046-globs-store-ordered
Reviewer Review Type Date Requested Status
Martin Packman (community) Approve on 2012-01-05
Jelmer Vernooij (community) 2011-12-22 Abstain on 2012-01-03
Review via email: mp+86734@code.launchpad.net

Commit Message

Provide a config section matcher respecting the file order.

Description of the Change

This fixes http://pad.lv/832046 by introducing a new section matcher that
respect the order specified by the user in the file.

That's the matcher I want to use for the new config files (defaults,
system-wide, etc) instead of locations.conf for which the order is less
predictable.

Once http://pad.lv/832042 is also fixed, that would address my concerns
about https://code.launchpad.net/~thomir/bzr/add-global-config/+merge/69592
which could then be taken into account.

To post a comment you must log in.
Vincent Ladeuil (vila) wrote :

Hit send too fast.

While I was there, I fixed a docstring issue in IniFileStore (which was intended for TransportIniFileStore) and also added an external_url to IniFileStore or we get weird NotImplemented exceptions when trying to %r such a store during debug.

I'm not sold on the StartingPathMatcher name (which I think capture the most important use case) and used GlobOrderedMatcher in a previous version (which I find uninformative). Suggestions welcome.

Jelmer Vernooij (jelmer) wrote :

Rather than this:

55 + if location.startswith('file://'):
56 + location = urlutils.local_path_from_url(location)

shouldn't this be using urlutils.unescape_for_display() ?

Vincent Ladeuil (vila) wrote :

> Rather than this:
>
> 55 + if location.startswith('file://'):
> 56 + location = urlutils.local_path_from_url(location)
>
> shouldn't this be using urlutils.unescape_for_display() ?

We've been using the same for locations.conf so I'm quite sure it's correct :)

We want to get a local path if possible and unescape_for_display won't do that AFAIK.

Jelmer Vernooij (jelmer) wrote :

On 12/23/2011 08:05 AM, Vincent Ladeuil wrote:
>> Rather than this:
>>
>> 55 + if location.startswith('file://'):
>> 56 + location = urlutils.local_path_from_url(location)
>>
>> shouldn't this be using urlutils.unescape_for_display() ?
>
> We've been using the same for locations.conf so I'm quite sure it's
correct :)
>
> We want to get a local path if possible and unescape_for_display won't
do that AFAIK.
unescape_for_display also handles local paths. I guess this is fine for
now if it was already there, it just seems to duplicate some code
unnecessarily.

Cheers,

Jelmer

Vincent Ladeuil (vila) wrote :

> unescape_for_display also handles local paths. I guess this is fine for
> now if it was already there, it just seems to duplicate some code
> unnecessarily.

I don't get it, what is duplicated here ?

Jelmer Vernooij (jelmer) wrote :

> > unescape_for_display also handles local paths. I guess this is fine for
> > now if it was already there, it just seems to duplicate some code
> > unnecessarily.
>
> I don't get it, what is duplicated here ?
The code which converts a URL to a section name in locations.conf, as it seems to be essentially the same thing as urlutils.unescape_for_display.

This all seems a bit magical - special casing local file paths. I wonder if it would make more sense to just convert everything to normalized URLs (using location_to_url) and then just compare those? That removes the need for special casing, and should also take care of any issues with characters that are e.g. escaped in the locations.conf file and not in the specified URL and vice versa.

Vincent Ladeuil (vila) wrote :

> > > unescape_for_display also handles local paths. I guess this is fine for
> > > now if it was already there, it just seems to duplicate some code
> > > unnecessarily.
> >
> > I don't get it, what is duplicated here ?
> The code which converts a URL to a section name in locations.conf, as it seems
> to be essentially the same thing as urlutils.unescape_for_display.

With no encoding involved :-/

>
> This all seems a bit magical - special casing local file paths. I wonder if it
> would make more sense to just convert everything to normalized URLs (using
> location_to_url) and then just compare those?

Well, that's old magic which works, I'd rather not fix it.

> That removes the need for
> special casing, and should also take care of any issues with characters that
> are e.g. escaped in the locations.conf file and not in the specified URL and
> vice versa.

So far, I mimic what is done in locations.conf, we can revisit that for both StartingPathMatcher and LocationMatcher but that seems out of scope for this proposal.

6409. By Vincent Ladeuil on 2012-01-03

Cleanup a bit and make sure we use at least one file:// url in the tests.

Jelmer Vernooij (jelmer) :
review: Abstain
Vincent Ladeuil (vila) wrote :

To clarify: I looked at using unescape_for_display but its API requires 7bits urls which is fine for location (or should be) but not for sections ids (they are already unicode) and there is not a lot of tests for file:/// urls either :-/

Jelmer Vernooij (jelmer) wrote :

I think at the very least this should have tests for URLs in locations.conf, and for matching local file URLs against paths and vice versa.

Martin Packman (gz) wrote :

The StartingPathMatcher logic is the confusing part, particularly what 'location' is expected to be. Stating it's inherited from what existing function, and listing limitations would help I think.

Using unescape_for_display wouldn't really help I don't think, and doesn't make much sense - the result needs operating on, not displaying. Normalising the location is wanted, but to a certain extent we can expect people to write their rules consistently. Likewise, splitting components in a sane manner would be nice but mostly people just want path segments handled correctly.

6410. By Vincent Ladeuil on 2012-01-04

Feedback from review.

6411. By Vincent Ladeuil on 2012-01-05

Tweak news entry to conflict on merge.

Martin Packman (gz) wrote :

Changes look good provided the new local_path tests don't break on windows from using nix-style paths.

review: Approve
Vincent Ladeuil (vila) wrote :

sent to pqm by email

Vincent Ladeuil (vila) wrote :

sent to pqm by email

Vincent Ladeuil (vila) wrote :

sent to pqm by email

Vincent Ladeuil (vila) wrote :

sent to pqm by email

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'bzrlib/config.py'
2--- bzrlib/config.py 2012-01-05 14:10:32 +0000
3+++ bzrlib/config.py 2012-01-05 16:49:28 +0000
4@@ -3114,10 +3114,6 @@
5 class IniFileStore(Store):
6 """A config Store using ConfigObj for storage.
7
8- :ivar transport: The transport object where the config file is located.
9-
10- :ivar file_name: The config file basename in the transport directory.
11-
12 :ivar _config_obj: Private member to hold the ConfigObj instance used to
13 serialize/deserialize the config file.
14 """
15@@ -3253,9 +3249,19 @@
16 value = self._config_obj._unquote(value)
17 return value
18
19+ def external_url(self):
20+ # Since an IniFileStore can be used without a file (at least in tests),
21+ # it's better to provide something than raising a NotImplementedError.
22+ # All daughter classes are supposed to provide an implementation
23+ # anyway.
24+ return 'In-Process Store, no URL'
25
26 class TransportIniFileStore(IniFileStore):
27 """IniFileStore that loads files from a transport.
28+
29+ :ivar transport: The transport object where the config file is located.
30+
31+ :ivar file_name: The config file basename in the transport directory.
32 """
33
34 def __init__(self, transport, file_name):
35@@ -3435,9 +3441,8 @@
36
37 class LocationSection(Section):
38
39- def __init__(self, section, length, extra_path):
40+ def __init__(self, section, extra_path):
41 super(LocationSection, self).__init__(section.id, section.options)
42- self.length = length
43 self.extra_path = extra_path
44 self.locals = {'relpath': extra_path,
45 'basename': urlutils.basename(extra_path)}
46@@ -3465,6 +3470,53 @@
47 return value
48
49
50+class StartingPathMatcher(SectionMatcher):
51+ """Select sections for a given location respecting the Store order."""
52+
53+ # FIXME: Both local paths and urls can be used for section names as well as
54+ # ``location`` to stay consistent with ``LocationMatcher`` which itself
55+ # inherited the fuzziness from the previous ``LocationConfig``
56+ # implementation. We probably need to revisit which encoding is allowed for
57+ # both ``location`` and section names and how we normalize
58+ # them. http://pad.lv/85479, http://pad.lv/437009 and http://359320 are
59+ # related too. -- vila 2012-01-04
60+
61+ def __init__(self, store, location):
62+ super(StartingPathMatcher, self).__init__(store)
63+ if location.startswith('file://'):
64+ location = urlutils.local_path_from_url(location)
65+ self.location = location
66+
67+ def get_sections(self):
68+ """Get all sections matching ``location`` in the store.
69+
70+ The most generic sections are described first in the store, then more
71+ specific ones can be provided for reduced scopes.
72+
73+ The returned section are therefore returned in the reversed order so
74+ the most specific ones can be found first.
75+ """
76+ location_parts = self.location.rstrip('/').split('/')
77+ store = self.store
78+ sections = []
79+ # Later sections are more specific, they should be returned first
80+ for _, section in reversed(list(store.get_sections())):
81+ if section.id is None:
82+ # The no-name section is always included if present
83+ yield store, LocationSection(section, self.location)
84+ continue
85+ section_path = section.id
86+ if section_path.startswith('file://'):
87+ # the location is already a local path or URL, convert the
88+ # section id to the same format
89+ section_path = urlutils.local_path_from_url(section_path)
90+ if (self.location.startswith(section_path)
91+ or fnmatch.fnmatch(self.location, section_path)):
92+ section_parts = section_path.rstrip('/').split('/')
93+ extra_path = '/'.join(location_parts[len(section_parts):])
94+ yield store, LocationSection(section, extra_path)
95+
96+
97 class LocationMatcher(SectionMatcher):
98
99 def __init__(self, store, location):
100@@ -3494,7 +3546,7 @@
101 matching_sections = []
102 if no_name_section is not None:
103 matching_sections.append(
104- LocationSection(no_name_section, 0, self.location))
105+ (0, LocationSection(no_name_section, self.location)))
106 for section_id, extra_path, length in filtered_sections:
107 # a section id is unique for a given store so it's safe to take the
108 # first matching section while iterating. Also, all filtered
109@@ -3504,7 +3556,7 @@
110 section = iter_all_sections.next()
111 if section_id == section.id:
112 matching_sections.append(
113- LocationSection(section, length, extra_path))
114+ (length, LocationSection(section, extra_path)))
115 break
116 return matching_sections
117
118@@ -3513,10 +3565,10 @@
119 matching_sections = self._get_matching_sections()
120 # We want the longest (aka more specific) locations first
121 sections = sorted(matching_sections,
122- key=lambda section: (section.length, section.id),
123+ key=lambda (length, section): (length, section.id),
124 reverse=True)
125 # Sections mentioning 'ignore_parents' restrict the selection
126- for section in sections:
127+ for _, section in sections:
128 # FIXME: We really want to use as_bool below -- vila 2011-04-07
129 ignore = section.get('ignore_parents', None)
130 if ignore is not None:
131
132=== modified file 'bzrlib/tests/test_config.py'
133--- bzrlib/tests/test_config.py 2012-01-05 10:39:49 +0000
134+++ bzrlib/tests/test_config.py 2012-01-05 16:49:28 +0000
135@@ -3336,8 +3336,7 @@
136
137 def get_section(self, options, extra_path):
138 section = config.Section('foo', options)
139- # We don't care about the length so we use '0'
140- return config.LocationSection(section, 0, extra_path)
141+ return config.LocationSection(section, extra_path)
142
143 def test_simple_option(self):
144 section = self.get_section({'foo': 'bar'}, '')
145@@ -3380,9 +3379,7 @@
146 '/quux/quux'],
147 [section.id for _, section in store.get_sections()])
148 matcher = config.LocationMatcher(store, '/foo/bar/quux')
149- sections = [section for s, section in matcher.get_sections()]
150- self.assertEquals([3, 2],
151- [section.length for section in sections])
152+ sections = [section for _, section in matcher.get_sections()]
153 self.assertEquals(['/foo/bar', '/foo'],
154 [section.id for section in sections])
155 self.assertEquals(['quux', 'bar/quux'],
156@@ -3399,9 +3396,7 @@
157 self.assertEquals(['/foo', '/foo/bar'],
158 [section.id for _, section in store.get_sections()])
159 matcher = config.LocationMatcher(store, '/foo/bar/baz')
160- sections = [section for s, section in matcher.get_sections()]
161- self.assertEquals([3, 2],
162- [section.length for section in sections])
163+ sections = [section for _, section in matcher.get_sections()]
164 self.assertEquals(['/foo/bar', '/foo'],
165 [section.id for section in sections])
166 self.assertEquals(['baz', 'bar/baz'],
167@@ -3432,6 +3427,90 @@
168 self.assertEquals(expected_location, matcher.location)
169
170
171+class TestStartingPathMatcher(TestStore):
172+
173+ def setUp(self):
174+ super(TestStartingPathMatcher, self).setUp()
175+ # Any simple store is good enough
176+ self.store = config.IniFileStore()
177+
178+ def assertSectionIDs(self, expected, location, content):
179+ self.store._load_from_string(content)
180+ matcher = config.StartingPathMatcher(self.store, location)
181+ sections = list(matcher.get_sections())
182+ self.assertLength(len(expected), sections)
183+ self.assertEqual(expected, [section.id for _, section in sections])
184+ return sections
185+
186+ def test_empty(self):
187+ self.assertSectionIDs([], self.get_url(), '')
188+
189+ def test_url_vs_local_paths(self):
190+ # The matcher location is an url and the section names are local paths
191+ sections = self.assertSectionIDs(['/foo/bar', '/foo'],
192+ 'file:///foo/bar/baz', '''\
193+[/foo]
194+[/foo/bar]
195+''')
196+
197+ def test_local_path_vs_url(self):
198+ # The matcher location is a local path and the section names are urls
199+ sections = self.assertSectionIDs(['file:///foo/bar', 'file:///foo'],
200+ '/foo/bar/baz', '''\
201+[file:///foo]
202+[file:///foo/bar]
203+''')
204+
205+
206+ def test_no_name_section_included_when_present(self):
207+ # Note that other tests will cover the case where the no-name section
208+ # is empty and as such, not included.
209+ sections = self.assertSectionIDs(['/foo/bar', '/foo', None],
210+ '/foo/bar/baz', '''\
211+option = defined so the no-name section exists
212+[/foo]
213+[/foo/bar]
214+''')
215+ self.assertEquals(['baz', 'bar/baz', '/foo/bar/baz'],
216+ [s.locals['relpath'] for _, s in sections])
217+
218+ def test_order_reversed(self):
219+ self.assertSectionIDs(['/foo/bar', '/foo'], '/foo/bar/baz', '''\
220+[/foo]
221+[/foo/bar]
222+''')
223+
224+ def test_unrelated_section_excluded(self):
225+ self.assertSectionIDs(['/foo/bar', '/foo'], '/foo/bar/baz', '''\
226+[/foo]
227+[/foo/qux]
228+[/foo/bar]
229+''')
230+
231+ def test_glob_included(self):
232+ sections = self.assertSectionIDs(['/foo/*/baz', '/foo/b*', '/foo'],
233+ '/foo/bar/baz', '''\
234+[/foo]
235+[/foo/qux]
236+[/foo/b*]
237+[/foo/*/baz]
238+''')
239+ # Note that 'baz' as a relpath for /foo/b* is not fully correct, but
240+ # nothing really is... as far using {relpath} to append it to something
241+ # else, this seems good enough though.
242+ self.assertEquals(['', 'baz', 'bar/baz'],
243+ [s.locals['relpath'] for _, s in sections])
244+
245+ def test_respect_order(self):
246+ self.assertSectionIDs(['/foo', '/foo/b*', '/foo/*/baz'],
247+ '/foo/bar/baz', '''\
248+[/foo/*/baz]
249+[/foo/qux]
250+[/foo/b*]
251+[/foo]
252+''')
253+
254+
255 class TestNameMatcher(TestStore):
256
257 def setUp(self):
258
259=== modified file 'doc/developers/configuration.txt'
260--- doc/developers/configuration.txt 2011-12-22 18:37:21 +0000
261+++ doc/developers/configuration.txt 2012-01-05 16:49:28 +0000
262@@ -301,12 +301,15 @@
263
264 ``bzrlib`` provides:
265
266-* ``LocationMatcher(store, location)``: To select all sections that match
267- ``location``.
268-
269 * ``NameMatcher(store, unique_id)``: To select a single section matching
270 ``unique_id``.
271
272+* ``LocationMatcher(store, location)``: To select all sections that match
273+ ``location`` sorted by decreasing number of path components.
274+
275+* ``StartingPathMatcher(store, location)``: To select all sections that
276+ match ``location`` in the order they appear in the ``store``.
277+
278 Stacks
279 ------
280
281
282=== modified file 'doc/en/release-notes/bzr-2.5.txt'
283--- doc/en/release-notes/bzr-2.5.txt 2012-01-05 11:39:43 +0000
284+++ doc/en/release-notes/bzr-2.5.txt 2012-01-05 16:49:28 +0000
285@@ -82,11 +82,19 @@
286 of revisions whose ancestry is not obviously on the same developement
287 line. (Vincent Ladeuil, #904744)
288
289+<<<<<<< TREE
290 * Make lazy imports resilient when resolved concurrently from multiple
291 threads. Now the stand-in object will behave as a proxy for the real object
292 after the initial access, rather than throwing. Assigning the object to
293 multiple names should still be avoided. (Martin von Gagern, #396819)
294
295+=======
296+* Configuration stacks can now use ``StartingPathMatcher`` to select the
297+ sections matching a location while respecting the order chosen by the user
298+ in the configuration file: from generic sections to specific
299+ sections. (Vincent Ladeuil, #832046).
300+
301+>>>>>>> MERGE-SOURCE
302 * Not setting ``gpg_signing_key`` or setting it to ``default`` will use the
303 user email (obtained from the ``email`` configuration option or its
304 default value). (Vincent Ladeuil, Jelmer Vernooij, #904550)