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

Last change on this file was 1803, checked in by muness, 17 years ago

Lauren LaGarde's patch to allow spaces in tags.

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