source: tagsplugin/tags/0.3rc1/tractags/api.py

Last change on this file was 1786, checked in by Alec Thomas, 18 years ago
  • Per-tagspace get_tags() and get_tagged_names() now strip out redundant tagspace information.
  • Added more unit tests.
File size: 15.1 KB
Line 
1"""
2
3Implementation of a generic tagging API for Trac. The API lets plugins register
4use of a set of namespaces (tagspaces) then access and manipulate the tags in
5that tagspace.
6
7For integration of external programs, the API also allows other tag systems to
8be accessed transparently (see the ITaggingSystemProvider interface and the
9corresponding TaggingSystem class).
10
11Taggable names are contained in a tagspace and can be associated with any
12number of tags. eg. ('wiki', 'WikiStart', 'start') represents a 'start' tag to
13the 'WikiStart' page in the 'wiki' tagspace.
14
15For a component to register a new tagspace for use it must implement the
16ITagSpaceUser interface.
17
18To access tags for a tagspace use the following mechanism (using the 'wiki'
19tagspace in this example):
20
21{{{
22#!python
23from tractags.api import TagEngine
24
25tags = TagEngine(env).tagspace.wiki
26# Display all names and the tags associated with each name
27for name in tags.get_tagged_names():
28    print name, list(tags.get_name_tags(name))
29# Display all tags and the names associated with each tag
30for tag in tags.get_tags():
31    print tag, list(tags.get_tagged_names(tag))
32# Add a start tag to WikiStart
33tags.add_tags(None, 'WikiStart', ['start'])
34}}}
35
36"""
37
38from trac.core import *
39from trac.env import IEnvironmentSetupParticipant
40from trac.db import Table, Column, Index
41import sys
42import re
43
44try:
45    set = set
46except:
47    from sets import Set as set
48
49class ITagSpaceUser(Interface):
50    """ Register that this component uses a set of tagspaces. If a tagspace is
51        not registered, it can not be used. """
52    def tagspaces_used():
53        """ Return an iterator of tagspaces used by this plugin. """
54
55class ITaggingSystemProvider(Interface):
56    """ An implementation of a tag system. This allows other non-Trac-native
57        tag systems to be accessed through one API. """
58
59    def get_tagspaces_provided():
60        """ Iterable of tagspaces provided by this tag system. """
61
62    def get_tagging_system(tagspace):
63        """ Return the TaggingSystem responsible for tagspace. """
64
65class TaggingSystem(object):
66    """ An implementation of a tagging system. """
67    def __init__(self, env, tagspace):
68        self.env = env
69        self.tagspace = tagspace
70
71    def _apply_tagged_names_operation(self, names, tags, operation):
72        """ Given a dictionary of tag:names, return the appropriate set of
73            names with the operation applied. """
74
75    def walk_tagged_names(self, names, tags, predicate):
76        """ Generator returning a tuple of (name, tags) for each tagged name
77            in this tagspace that meets the predicate and is in the set
78            of names and has any of the given tags.
79
80            (The names and tags arguments are purely an optimisation
81            opportunity for the underlying TaggingSystem)
82            """
83
84    def get_name_tags(self, name):
85        """ Get tags for a name. """
86        raise NotImplementedError
87
88    def add_tags(self, req, name, tags):
89        """ Tag name in tagspace with tags. """
90        raise NotImplementedError
91
92    def replace_tags(self, req, name, tags):
93        """ Replace existing tags on name with tags. """
94        self.remove_all_tags(req, name)
95        self.add_tags(req, name, tags)
96
97    def remove_tags(self, req, name, tags):
98        """ Remove tags from a name in a tagspace. """
99        raise NotImplementedError
100
101    def remove_all_tags(self, req, name):
102        """ Remove all tags from a name in a tagspace. """
103        self.remove_tags(req, name, self.get_name_tags(name))
104
105    def name_details(self, name):
106        """ Return a tuple of (href, htmllink, title). eg.
107            ("/ticket/1", "<a href="/ticket/1">#1</a>", "Broken links") """
108        raise NotImplementedError
109
110class DefaultTaggingSystem(TaggingSystem):
111    """ Default tagging system. Handles any number of namespaces registered via
112        ITagSpaceUser. """
113
114    def walk_tagged_names(self, names, tags, predicate):
115        db = self.env.get_db_cnx()
116        cursor = db.cursor()
117
118        args = [self.tagspace]
119        sql = 'SELECT DISTINCT name, tag FROM tags WHERE tagspace=%s'
120        if names:
121            sql += ' AND name IN (' + ', '.join(['%s' for n in names]) + ')'
122            args += names
123        if tags:
124            sql += ' AND name in (SELECT name FROM tags WHERE tag in (' + ', '.join(['%s' for t in tags]) + '))'
125            args += tags
126        sql += " ORDER BY name"
127        cursor.execute(sql, args)
128
129        tags = set(tags)
130        current_name = None
131        name_tags = set()
132        for name, tag in cursor:
133            if current_name != name:
134                if current_name is not None:
135                    if predicate(current_name, name_tags):
136                        yield (current_name, name_tags)
137                name_tags = set([tag])
138                current_name = name
139            else:
140                name_tags.add(tag)
141        if current_name is not None and predicate(current_name, name_tags):
142            yield (current_name, name_tags)
143
144    def get_name_tags(self, name):
145        db = self.env.get_db_cnx()
146        cursor = db.cursor()
147        cursor.execute('SELECT tag FROM tags WHERE tagspace=%s AND name=%s', (self.tagspace, name))
148        return set([row[0] for row in cursor])
149
150    def add_tags(self, req, name, tags):
151        db = self.env.get_db_cnx()
152        cursor = db.cursor()
153        for tag in tags:
154            cursor.execute('INSERT INTO tags (tagspace, name, tag) VALUES (%s, %s, %s)', (self.tagspace, name, tag))
155        db.commit()
156
157    def remove_tags(self, req, name, tags):
158        db = self.env.get_db_cnx()
159        cursor = db.cursor()
160        sql = "DELETE FROM tags WHERE tagspace = %s AND name = %s AND tag " \
161              "IN (" + ', '.join(["%s" for t in tags]) + ")"
162        cursor.execute(sql, (self.tagspace, name) + tuple(tags))
163        db.commit()
164
165    def remove_all_tags(self, req, name):
166        db = self.env.get_db_cnx()
167        cursor = db.cursor()
168        cursor.execute('DELETE FROM tags WHERE tagspace=%s AND name=%s', (self.tagspace, name))
169        db.commit()
170       
171    def name_details(self, name):
172        from trac.wiki.formatter import wiki_to_oneliner
173        return (getattr(self.env.href, self.tagspace),
174                wiki_to_oneliner('[%s:%s %s]' % (self.tagspace, name, name),
175                                  self.env), '')
176
177class TagspaceProxy:
178    """ A convenience for performing operations on a specific tagspace,
179        including get_tags() and get_tagged_names(). Both of these functions
180        will only search that tagspace, and will return values stripped of
181        tagspace information. """
182    def __init__(self, engine, tagspace):
183        self.engine = engine
184        self.tagspace = tagspace
185        self.tagsystem = engine._get_tagsystem(tagspace)
186
187    def get_tags(self, *args, **kwargs):
188        result = self.engine.get_tags(tagspaces=[self.tagspace], *args, **kwargs)
189        if isinstance(result, set):
190            return result
191        else:
192            out = {}
193            for tag, names in result.iteritems():
194                out[tag] = set([name for _, name in names])
195            return out
196
197    def get_tagged_names(self, *args, **kwargs):
198        return self.engine.get_tagged_names(tagspaces=[self.tagspace], *args, **kwargs)[self.tagspace]
199
200    def __getattr__(self, attr):
201        return getattr(self.tagsystem, attr)
202
203class TagspaceDirector(object):
204    """ A convenience similar to env.href, proxying to the correct TagSystem by
205        attribute. """
206    def __init__(self, engine):
207        self.engine = engine
208
209    def __getattr__(self, tagspace):
210        return self.tagspace(tagspace)
211
212    def tagspace(self, tagspace):
213        return TagspaceProxy(self.engine, tagspace)
214
215class TagEngine(Component):
216    """ The core of the Trac tag API. This interface can be used to register
217        tagspaces (ITagSpaceUser or register_tagspace()), add other tagging
218        systems (ITaggingSystemProvider), and to control tags in a tagspace.
219    """
220
221    _tagspace_re = re.compile(r'''^[a-zA-Z_][a-zA-Z0-9_]*$''')
222
223    implements(ITaggingSystemProvider, IEnvironmentSetupParticipant)
224
225    tag_users = ExtensionPoint(ITagSpaceUser)
226    tagging_systems = ExtensionPoint(ITaggingSystemProvider)
227
228    SCHEMA = [
229        Table('tags', key = ('tagspace', 'name', 'tag'))[
230              Column('tagspace'),
231              Column('name'),
232              Column('tag'),
233              Index(['tagspace', 'name']),
234              Index(['tagspace', 'tag']),]
235        ]
236
237    def __init__(self):
238        self.tagspace = TagspaceDirector(self)
239        self._tagsystem_cache = {}
240
241    def _get_tagspaces(self):
242        """ Get iterable of available tagspaces. """
243        out = []
244        for tagsystem in self.tagging_systems:
245            for tagspace in tagsystem.get_tagspaces_provided():
246                out.append(tagspace)
247        return out
248    tagspaces = property(_get_tagspaces)
249
250    def _get_tagsystem(self, tagspace):
251        """ Returns a TaggingSystem proxy object with tagspace as the default
252            tagspace. """
253        try:
254            return self._tagsystem_cache[tagspace]
255        except KeyError:
256            for tagsystem in self.tagging_systems:
257                if tagspace in tagsystem.get_tagspaces_provided():
258                   self._tagsystem_cache[tagspace] = tagsystem.get_tagging_system(tagspace)
259                   return self._tagsystem_cache[tagspace]
260        raise TracError("No such tagspace '%s'" % tagspace)
261
262    # Public methods
263    def walk_tagged_names(self, names=[], tags=[], tagspaces=[], predicate=lambda tagspace, name, tags: True):
264        """ Generator returning (tagspace, name, tags) for all names in the
265            given tagspaces. Objects must have at least one of tags, be in
266            names and must meet the predicate. """
267        tagspaces = tagspaces or self.tagspaces
268        for tagspace in tagspaces:
269            tagsystem = self._get_tagsystem(tagspace)
270            for name, name_tags in tagsystem.walk_tagged_names(names=names, tags=tags, predicate=lambda n, t: predicate(tagspace, n, t)):
271                yield (tagspace, name, name_tags)
272
273    def get_tags(self, names=[], tagspaces=[], operation='union', detailed=False):
274        """ Get tags with the given names from the given tagspaces.
275            'operation' is the union or intersection of all tags on
276            names. If detailed, return a set of
277            {tag:set([(tagspace, name), ...])}, otherwise return a set of
278            tags. """
279        assert type(names) in (list, tuple, set)
280        tagspaces = tagspaces or self.tagspaces
281        seed_set = True
282        all_tags = set()
283        tagged_names = {}
284        for tagspace, name, tags in self.walk_tagged_names(names=names, tagspaces=tagspaces):
285            for tag in tags:
286                tagged_names.setdefault(tag, set()).add((tagspace, name))
287            if operation == 'intersection':
288                if seed_set:
289                    seed_set = False
290                    all_tags.update(tags)
291                else:
292                    all_tags.intersection_update(tags)
293                    if not all_tags:
294                        return detailed and {} or set()
295            else:
296                all_tags.update(tags)
297        if detailed:
298            out_tags = {}
299            for tag in all_tags:
300                out_tags[tag] = tagged_names[tag]
301            return out_tags
302        else:
303            return all_tags
304
305    def get_tagged_names(self, tags=[], tagspaces=[], operation='intersection', detailed=False):
306        """ Get names with the given tags from tagspaces. 'operation' is the set
307            operatin to perform on the sets of names tagged with each of the
308            search tags, and can be either 'intersection' or 'union'.
309
310            If detailed=True return a dictionary of
311            {tagspace:{name:set([tag, ...])}} otherwise return a dictionary of
312            {tagspace:set([name, ...])}. """
313        assert type(tags) in (list, tuple, set)
314        tagspaces = tagspaces or self.tagspaces
315        tags = set(tags)
316        if detailed:
317            output = dict([(ts, {}) for ts in tagspaces])
318        else:
319            output = dict([(ts, set()) for ts in tagspaces])
320        for tagspace, name, name_tags in self.walk_tagged_names(tags=tags, tagspaces=tagspaces):
321            if operation == 'intersection' and tags.intersection(name_tags) != tags:
322                continue
323            if detailed:
324                output[tagspace][name] = name_tags
325            else:
326                output[tagspace].add(name)
327        return output
328
329    def get_tag_link(self, tag):
330        """ Return (href, title) to information about tag. This first checks for
331            a Wiki page named <tag>, then uses /tags/<tag>. """
332        from tractags.wiki import WikiTaggingSystem
333        page, title = WikiTaggingSystem(self.env).page_info(tag)
334        if page.exists:
335            return (self.env.href.wiki(tag), title)
336        else:
337            return (self.env.href.tags(tag), "Objects tagged ''%s''" % tag)
338
339    def name_details(self, tagspace, name):
340        """ Return a tuple of (href, htmllink, title). eg.
341            ("/ticket/1", "<a href="/ticket/1">#1</a>", "Broken links") """
342        return self._get_tagsystem(tagspace).name_details(name)
343
344    # ITaggingSystemProvider methods
345    def get_tagspaces_provided(self):
346        for user in self.tag_users:
347            for tagspace in user.tagspaces_used():
348                yield tagspace
349
350    def get_tagging_system(self, tagspace):
351        for taguser in self.tag_users:
352            if tagspace in taguser.tagspaces_used():
353                return DefaultTaggingSystem(self.env, tagspace)
354        raise TracError("No such tagspace '%s'" % tagspace)
355
356    # IEnvironmentSetupParticipant methods
357    def environment_created(self):
358        self._upgrade_db(self.env.get_db_cnx())
359
360    def environment_needs_upgrade(self, db):
361        cursor = db.cursor()
362        if self._need_migration(db):
363            return True
364        try:
365            cursor.execute("select count(*) from tags")
366            cursor.fetchone()
367            return False
368        except:
369            db.rollback()
370            return True
371
372    def upgrade_environment(self, db):
373        self._upgrade_db(db)
374
375    def _need_migration(self, db):
376        cursor = db.cursor()
377        try:
378            cursor.execute("select count(*) from wiki_namespace")
379            cursor.fetchone()
380            self.env.log.debug("tractags needs to migrate old data")
381            return True
382        except:
383            db.rollback()
384            return False
385
386    def _upgrade_db(self, db):
387        try:
388            try:
389                from trac.db import DatabaseManager
390                db_backend, _ = DatabaseManager(self.env)._get_connector()
391            except ImportError:
392                db_backend = self.env.get_db_cnx()
393
394            cursor = db.cursor()
395            for table in self.SCHEMA:
396                for stmt in db_backend.to_sql(table):
397                    self.env.log.debug(stmt)
398                    cursor.execute(stmt)
399
400            # Migrate old data
401            if self._need_migration(db):
402                cursor.execute("INSERT INTO tags (tagspace, name, tag) SELECT 'wiki', name, namespace FROM wiki_namespace")
403                cursor.execute("DROP TABLE wiki_namespace")
404        except Exception, e:
405            db.rollback()
406            raise TracError(str(e))
407
408        db.commit()
409
Note: See TracBrowser for help on using the repository browser.