source: tagsplugin/tags/0.5/tractags/api.py

Last change on this file was 2568, checked in by Alec Thomas, 16 years ago

TagsPlugin:

A few fixes picked up by garyo@…, thanks. References #1932.

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