home *** CD-ROM | disk | FTP | other *** search
/ Mac Easy 2010 May / Mac Life Ubuntu.iso / casper / filesystem.squashfs / usr / share / pyshared / launchpadbugs / html_bug.py < prev    next >
Encoding:
Python Source  |  2009-03-12  |  60.7 KB  |  1,528 lines

  1. """
  2. TODO:
  3.     * TODOs are specified in the classes
  4.     * being more verbose in 'container'-object's __repr__: add bugnumber to each object
  5.     * standardize *.changed (output: frozenset/list, property)
  6.     * how to handle global attachment-related variables?
  7.     * add revert()-function(s)
  8. THIS IS STILL WORK IN PROGRESS!!
  9. """
  10. import re
  11. import os
  12. import libxml2
  13.  
  14. import exceptions
  15. from exceptions import parse_error
  16.  
  17. from bugbase import Bug as BugBase
  18. from lptime import LPTime
  19. from tasksbase import LPTasks, LPTask
  20. from attachmentsbase import LPAttachments, LPAttachment
  21. from commentsbase import LPComments, LPComment
  22. from lphelper import user, product, change_obj, unicode_for_libxml2
  23. from lpconstants import BUG, BASEURL
  24. from subscribersbase import LPSubscribers
  25. from utils import valid_lp_url
  26.         
  27. # deactivate error messages from the validation [libxml2.htmlParseDoc]
  28. def noerr(ctx, str):
  29.     pass
  30.  
  31. libxml2.registerErrorHandler(noerr, None)
  32.         
  33.  
  34. def _small_xpath(xml, expr):
  35.     """ Returns the content of the first result of a xpath expression  """
  36.     result = xml.xpathEval(expr)
  37.     if not result:
  38.         return False
  39.     return result[0].content
  40.         
  41. def get_bug(id):
  42.     return Bug._container_refs[id]
  43.  
  44.  
  45. def _blocked(func, error=None):
  46.     def f(a, *args, **kwargs):
  47.         try:
  48.             x = a.infotable.current
  49.         except AttributeError, e:
  50.             error = "%s %s" %(f.error, e)
  51.             raise AttributeError, error
  52.         return func(a, *args, **kwargs)
  53.     f.error = error or "Unable to get current InfoTable row."
  54.     return f 
  55.  
  56. def _attr_ext(i, s):
  57.     if i.startswith("__"):
  58.         return "_%s%s" %(s.__class__.__name__,i)
  59.     else:
  60.         return i
  61.  
  62.  
  63. def _gen_getter(attr):
  64.     """ Returns a function to return the value of an attribute
  65.     
  66.     Example:
  67.         get_example = _gen_getter("x.y")
  68.     is like:
  69.         def get_example(self):
  70.             if not x.parsed:
  71.                 x.parse()
  72.             return x.y
  73.     
  74.     """
  75.     def func(s):
  76.         attributes = attr.split(".")
  77.         attributes = map(lambda a: _attr_ext(a, s), attributes)
  78.         x = getattr(s, attributes[0])
  79.         attributes.insert(0, s)
  80.         if not x.parsed:
  81.             x.parse()
  82.         return reduce(getattr, attributes)
  83.     return func
  84.  
  85.         
  86. def _gen_setter(attr):
  87.     """ Returns a function to set the value of an attribute
  88.     
  89.     Example:
  90.         set_example = _gen_setter("x.y")
  91.     is like:
  92.         def set_example(self, value):
  93.             if not x.parsed:
  94.                 x.parse()
  95.             x.y = value
  96.     
  97.     """
  98.     def func(s, value):
  99.         attributes = attr.split(".")
  100.         attributes = map(lambda a: _attr_ext(a, s), attributes)
  101.         x = getattr(s, attributes[0])
  102.         attributes.insert(0, s)
  103.         if not x.parsed:
  104.             x.parse()
  105.         setattr(reduce(getattr, attributes[:-1]), attributes[-1], value)
  106.     return func
  107.  
  108. def create_project_task(project):
  109.     x = [None]*14
  110.     task = Info(product(project), *x)
  111.     task._type = "%s.product" %project
  112.     return task
  113.  
  114. def create_distro_task(distro=None, sourcepackage=None, url=None):
  115.     x = [None]*14
  116.     if distro is None:
  117.         distro = "Ubuntu"
  118.     if not sourcepackage is None:
  119.         affects = product("%s (%s)"%(sourcepackage, distro.title()))
  120.     else:
  121.         affects = product(distro.title())
  122.     task = Info(affects, *x)
  123.     task._type = "%s.sourcepackagename" %affects
  124.     task._remote = url
  125.     return task
  126.         
  127. class Info(LPTask):
  128.     """ The 'Info'-object represents one row of the 'InfoTable' of a bugreport
  129.     
  130.     * editable attributes:
  131.         .sourcepackage: lp-name of a package/project
  132.         .status: valid lp-status
  133.         .importance: valid lp-importance (if the user is not permitted to
  134.             change 'importance' an 'IOError' will be raised
  135.         .assignee: lp-login of an user/group
  136.         .milestone: value must be in '.valid_milestones'
  137.         
  138.     * read-only attributes:
  139.         .affects, .target, .valid_milestones
  140.         
  141.     TODO: * rename 'Info' into 'Task'
  142.     """
  143.     def __init__(self, affects, status, importance, assignee, current,
  144.                     editurl, type, milestone, available_milestone,
  145.                     lock_importance, targeted_to, remote, editlock,
  146.                     edit_fields, connection):
  147.         data_dict = { "affects": affects,
  148.                       "status": status,
  149.                       "importance": importance,
  150.                       "assignee": assignee,
  151.                       "current": current,
  152.                       "editurl": editurl,
  153.                       "type": type,
  154.                       "milestone": milestone,
  155.                       "available_milestone": available_milestone,
  156.                       "lock_importance": lock_importance,
  157.                       "targeted_to": targeted_to,
  158.                       "remote": remote,
  159.                       "editlock": editlock,
  160.                       "edit_fields": edit_fields,
  161.                       "connection": connection}
  162.                         
  163.         LPTask.__init__(self, data_dict)
  164.         self._cache = {"sourcepackage" : self._sourcepackage,
  165.                 "status": self._status, "importance": self._importance,
  166.                 "assignee": self._assignee, "milestone": self._milestone}
  167.                         
  168.     def set_sourcepackage(self, package):
  169.         if self._editlock:
  170.             raise IOError, "The sourcepackage of this bug can't be edited, maybe because this bug is a duplicate of an other one"
  171.         self._sourcepackage = package
  172.         
  173.     def set_assignee(self, lplogin):
  174.         if self._editlock:
  175.             raise IOError, "The assignee of this bug can't be edited, maybe because this bug is a duplicate of an other one"
  176.         if self._remote:
  177.             raise IOError, "This task is linked to a remote-bug system, please change the assignee there"
  178.         self._assignee = lplogin
  179.         
  180.     def set_status(self, status):
  181.         if self._editlock:
  182.             raise IOError, "The status of this bug can't be edited, maybe because this bug is a duplicate of an other one"
  183.         if self._remote:
  184.             raise IOError, "This task is linked to a remote-bug system, please change the status there"
  185.         if status not in BUG.STATUS.values():
  186.             raise ValueError, "Unknown status '%s', status must be one of these: %s" %(status, BUG.STATUS.values())
  187.         self._status = status
  188.         
  189.     def set_importance(self, importance):
  190.         if self._editlock:
  191.             raise IOError, "The importance of this bug can't be edited, maybe because this bug is a duplicate of an other one"
  192.         if self._remote:
  193.             raise IOError, "This task is linked to a remote-bug system, please change the importance there"
  194.         if self._lock_importance:
  195.             raise IOError, "'Importance' changeable only by a project maintainer or bug contact"
  196.         if importance not in BUG.IMPORTANCE.values():
  197.             raise ValueError, "Unknown importance '%s', importance must be one of these: %s" %(importance, BUG.IMPORTANCE.values())
  198.         self._importance = importance
  199.         
  200.     def set_milestone(self, milestone):
  201.         if self._editlock:
  202.             raise IOError, "The milestone of this bug can't be edited, maybe because this bug is a duplicate of an other one"
  203.         if not self.__available_milestone:
  204.             raise ValeError, "No milestones defined for this product"
  205.         if milestone not in self._available_milestone:
  206.             raise ValueError, "Unknown milestone, milestone must be one of these: %s" %self._available_milestone
  207.         self._milestone = milestone
  208.         
  209.     
  210.     def commit(self, force_changes=False, ignore_lp_errors=True):
  211.         """ Commits the local changes to launchpad.net
  212.         
  213.         * force_changes: general argument, has not effect in this case
  214.         * ignore_lp_errors: if the user tries to commit invalid data to launchpad,
  215.             launchpad returns an error-page. If 'ignore_lp_errors=False' Info.commit()
  216.             will raise an 'ValueError' in this case, otherwise ignore this
  217.             and leave the bugreport in launchpad unchanged (default=True)
  218.         """
  219.         changed = self.changed
  220.         if changed:
  221.             if self._type:
  222.                 full_sourcepackage = self._type[:self._type.rfind(".")]
  223.             else:
  224.                 full_sourcepackage = "%s_%s" %(self.targeted_to, str(self._affects))
  225.             s = self.sourcepackage
  226.             if s == "ubuntu":
  227.                 s = ""
  228.             args = { '%s.actions.save' %full_sourcepackage: '1',
  229.                 '%s.comment_on_change' %full_sourcepackage: ''# set_status_comment
  230.                     }
  231.             if self._type:
  232.                 args[self._type] = s
  233.             if ".status" in self._edit_fields:
  234.                 args['%s.status-empty-marker' %full_sourcepackage] = '1'
  235.                 args['%s.status' %full_sourcepackage] = self.status
  236.             if ".importance" in self._edit_fields:
  237.                 args['%s.importance' %full_sourcepackage] = self.importance
  238.                 args['%s.importance-empty-marker' %full_sourcepackage] = '1'
  239.             if ".milestone" in self._edit_fields:
  240.                 args['%s.mlestone' %full_sourcepackage] = ""
  241.                 args['%s.milestone-empty-marker' %full_sourcepackage] = '1'
  242.             args['%s.assignee.option' %full_sourcepackage] = '%s.assignee.assign_to' %full_sourcepackage
  243.             args['%s.assignee' %full_sourcepackage] = self.assignee or ""
  244.                 
  245.             result = self._connection.post(self._editurl, args)
  246.             if result.url.endswith("+editstatus") and not ignore_lp_errors:
  247.                 raise exceptions.PythonLaunchpadBugsValueError({"arguments": args},
  248.                     self._editurl, "one or more arguments might be wrong")
  249.  
  250.  
  251. class InfoTable(LPTasks):
  252.     """ The 'InfoTable'-object represents the tasks at the top of a bugreport
  253.         
  254.     * read-only attributes:
  255.         .current: returns the highlighted Info-object of the bugreport
  256.     
  257.     TODO:  * rename 'InfoTable' into 'TaskTable'
  258.            * allow adding of tasks (Also affects upstream/Also affects distribution)
  259.            * does current/tracked work as expected?
  260.            * remote: parse editable values
  261.     """
  262.     def __init__(self, connection, xml, url):
  263.         LPTasks.__init__(self, {"connection":connection, "url":url, "xml":xml})
  264.         self.__url = url
  265.         self.__xml = xml
  266.  
  267.     def parse(self):
  268.         """ Parsing the info-table
  269.         
  270.         * format:  'Affects'|'Status'|'Importance'|'Assigned To'
  271.         
  272.         TODO: * working on 'tracked in...' - currently there is only one 'tracked in'
  273.                 entry per bugreport supported
  274.               * REMOTE BUG!!!
  275.         """
  276.         if self.parsed:
  277.             return True
  278.         rows = self.__xml[0].xpathEval('tbody/tr[not(@style="display: none") and not(@class="secondary")]')
  279.         parse_error(self.__xml[0], "InfoTable.rows", xml=self.__xml, url=self.__url)
  280.         
  281.         highl_target = None
  282.         # status of (remote)-bugs can be 'unknown''
  283.         temp_status = BUG.STATUS.copy()
  284.         temp_status["statusUNKNOWN"] = "Unknown"
  285.         # importance of (remote)-bugs can be 'unknown''
  286.         temp_importance = BUG.IMPORTANCE.copy()
  287.         temp_importance["importanceUNKNOWN"] = "Unknown"
  288.         
  289.         tracked = False
  290.         affects = product(None)
  291.         for row in rows:
  292.             edit_fields = set()
  293.             tmp_affects = affects
  294.             current = False
  295.             remote = None
  296.                 
  297.             if row.prop("class") == "highlight":
  298.                 current = True
  299.                 
  300.             row_cells = row.xpathEval("td")
  301.             row_cells = [i for i in row_cells if not i.prop("class") == "icon left right"]
  302.             
  303.             if not tracked:
  304.                 # parse affects
  305.                 parse_error(row_cells[0], "InfoTable.affects.1", xml=row_cells, url=self.__url)
  306.                 m_type = row_cells[0].xpathEval("img")
  307.                 parse_error(m_type, "InfoTable.affects.2", xml=row_cells, url=self.__url)
  308.                 
  309.                 affects_type = m_type[0].prop("src").split("/")[-1]
  310.                 m_name = row_cells[0].xpathEval("a")
  311.                 parse_error(m_name, "InfoTable.affects.3", xml=row_cells, url=self.__url)
  312.                 
  313.                 affects_lpname = m_name[0].prop("href").split("/")[-1]
  314.                 affects_longname = m_name[0].content
  315.             affects = product(affects_lpname, affects_longname, affects_type)
  316.                 
  317.             if row_cells[1].prop("colspan"):
  318.                 tracked = True
  319.                 if current:
  320.                     highl_target = row_cells[1].xpathEval('span')[0].content.split("\n")[2].lstrip().rstrip()
  321.                     current = False
  322.                 continue # ignore "tracked in ..." - rows
  323.             tracked = False
  324.                 
  325.             targeted_to = None
  326.             if row_cells[0].xpathEval('img[@alt="Targeted to"]'):
  327.                 targeted_to = row_cells[0].xpathEval('a')[0].content
  328.                 affects = tmp_affects
  329.                 if highl_target:
  330.                     if targeted_to.lower() == highl_target.lower():
  331.                         current = True
  332.                             
  333.             xmledit = row.xpathEval('following-sibling::tr[@style="display: none"][1]')
  334.             
  335.             type = None
  336.             milestone = None
  337.             available_milestone = {}
  338.             editurl = None
  339.             editlock = False
  340.             lock_importance = False
  341.             
  342.             if xmledit:
  343.                 xmledit = xmledit[0]
  344.                 editurl = xmledit.xpathEval('td/form')
  345.                 parse_error(editurl, "InfoTable.editurl", xml=xmledit, url=self.__url)
  346.                 
  347.                 editurl = valid_lp_url(editurl[0].prop('action'), BASEURL.BUG)
  348.             
  349.                 if xmledit.xpathEval('descendant::label[contains(@for,"bugwatch")]'):
  350.                     x = xmledit.xpathEval('descendant::label[contains(@for,"bugwatch")]/a')
  351.                     if x:
  352.                         remote = x[0].prop("href")
  353.                     else:
  354.                         # if the status of the bug is updated manually.
  355.                         remote = True
  356.                     
  357.                 if not remote:
  358.                     for i in ["product", "sourcepackagename"]:
  359.                         x = xmledit.xpathEval('td/form/div//table//input[contains(@id,".%s")]' %i)
  360.                         if x:
  361.                             type = x[0].prop("id")
  362.                     if not type:
  363.                         if not row.xpathEval('td[2]//img[contains(@src,"milestone")]'):
  364.                             parse_error(False, "InfoTable.type.milestone", xml=xmledit, url=self.__url)
  365.                 
  366.                     m = xmledit.xpathEval('descendant::select[contains(@id,".milestone")]//option')
  367.                     if m:
  368.                         for i in m:
  369.                             available_milestone[i.prop("value")] = i.content
  370.                             if i.prop("selected"):
  371.                                 milestone = i.content
  372.                             
  373.                     m = xmledit.xpathEval('descendant::td[contains(@title, "Changeable only by a project maintainer") and count(span)=0]')
  374.                     if len(m) == 1 and not milestone:
  375.                         milestone = m[0].content.strip("\n ")
  376.             
  377.                 if not xmledit.xpathEval('descendant::select[contains(@id,".importance")]'):
  378.                     lock_importance = True
  379.                 m = set([".sourcepackagename", ".product", ".status", ".status-empty-marker",
  380.                             ".importance", ".importance-empty-marker", ".milestone" ,
  381.                             ".milestone-empty-marker"])
  382.                 for i in m:
  383.                     x = xmledit.xpathEval('td/form//input[contains(@name,"%s")]' %i)
  384.                     y = xmledit.xpathEval('td/form//select[contains(@name,"%s")]' %i)
  385.                     if x or y:
  386.                         edit_fields.add(i)
  387.             else:
  388.                 editlock = True
  389.                         
  390.             parse_error(row_cells[1], "InfoTable.status.1", xml=row_cells, url=self.__url)
  391.             parse_error(row_cells[1].prop("class") in temp_status , "InfoTable.status.2",
  392.                             msg="unknown bugstatus '%s' in InfoTable.parse()" %row_cells[1].prop("class"),
  393.                             error_type=exceptions.VALUEERROR,
  394.                             url=self.__url)
  395.             status = temp_status[row_cells[1].prop("class")]
  396.             
  397.             parse_error(row_cells[2], "InfoTable.importance.1", xml=row_cells, url=self.__url)
  398.             parse_error(row_cells[2].prop("class") in temp_importance, "InfoTable.importance.2",
  399.                             msg="unknown bugimportance '%s' in InfoTable.parse()" %row_cells[2].prop("class"),
  400.                             error_type=exceptions.VALUEERROR,
  401.                             url=self.__url)
  402.             importance = temp_importance[row_cells[2].prop("class")]
  403.             
  404.             assignee = row_cells[3].xpathEval("a")
  405.             if assignee:
  406.                 assignee = assignee[0]
  407.                 if remote:
  408.                     assignee = " ".join([i.lstrip().rstrip() for i in assignee.content.split("\n") if i.lstrip()])
  409.                 else:
  410.                     assignee = user.parse_html_user(assignee)
  411.             else:
  412.                 assignee = user(None)
  413.             
  414.             if current:
  415.                 self._current = len(self)
  416.             self.append(Info(affects, status, importance,
  417.                             assignee, current, editurl or self._url, type, milestone,
  418.                             available_milestone, lock_importance,
  419.                             targeted_to, remote, editlock, edit_fields,
  420.                             connection=self._connection))
  421.  
  422.         self.parsed = True
  423.         return True
  424.         
  425.     def _LP_create_task(self, task, force_changes, ignore_lp_errors):
  426.         tsk = task.component._type or ""
  427.         if tsk.endswith(".product"):
  428.             url = "%s/+choose-affected-product" %self._url
  429.             args = {"field.visited_steps": "choose_product, specify_remote_bug_url",
  430.                     "field.product": str(task.component.affects),
  431.                     "field.actions.continue": "Add to Bug Report"}
  432.             result = self._connection.post(url, args)
  433.             if result.url == url:
  434.                 # possible errors:
  435.                 #  * project does not exist
  436.                 #  * 'A fix for this bug has already been requested for ...'
  437.                 raise exceptions.choose_pylpbugsError(error_type=exceptions.VALUEERROR, text=result.text, url=url)
  438.         elif tsk.endswith(".sourcepackagename"):
  439.             url = "%s/+distrotask" %self._url
  440.             args = { "field.distribution": task.component.target or "ubuntu",
  441.                      "field.distribution-empty-marker": 1,
  442.                      "field.sourcepackagename": task.component.sourcepackage or "",
  443.                      "field.visited_steps": "specify_remote_bug_url",
  444.                      "field.bug_url": task.component.remote or "",
  445.                      "field.actions.continue": "Continue"}
  446.             result = self._connection.post(url, args)
  447.             if result.url == url:
  448.                 # possible errors
  449.                 # * "This bug is already open on <distro> with no package specified."
  450.                 # * "There is no package in <distro> named <sourcepackage>"
  451.                 # (not sure of other errors)
  452.                 raise exceptions.choose_pylpbugsError(error_type=exceptions.VALUEERROR, text=result.text, url=url)
  453.                 
  454.         else:
  455.             raise NotImplementedError
  456.         
  457.  
  458.    
  459. class BugReport(object):
  460.     """ The 'BugReport'-object is the report itself
  461.     
  462.     * editable attributes:
  463.         .description: any text
  464.         .title/.summary: any text
  465.         .tags: list, use list operations to add/remove tags
  466.         .nickname
  467.         
  468.     * read-only attributes:
  469.         .target: e.g. 'upstream'
  470.         .sourcepackage: 'None' if not package specified
  471.         .reporter, .date
  472.     """
  473.     def __init__(self, connection, xml, url):
  474.         [self.__title, self.__description, self.__tags, self.__nickname,
  475.         self.target, self.sourcepackage, self.reporter, self.date] = [None]*8
  476.         self.__cache = {}
  477.         self.parsed = False
  478.         self.__connection=connection
  479.         self.__xml = xml
  480.         self.__url = url
  481.         self.__description_raw = None
  482.         
  483.     def __repr__(self):
  484.         return "<BugReport>"
  485.  
  486.     def parse(self):
  487.         if self.parsed:
  488.             return True
  489.         
  490.         # getting text (description) of the bugreport
  491.         description = self.__xml.xpathEval('//body//div[@class="report"]/\
  492. div[@id="bug-description"]')
  493.         parse_error(description, "BugReport.description", xml=self.__xml, url=self.__url)
  494.         
  495.         # hack to change </p> into \n - any better idea? - NOT just eye-candy
  496.         # this is also the format needed when committing changes
  497.         p = description[0].xpathEval('p')
  498.         description = ""
  499.         for i in p[:-1]:
  500.             description = "".join([description,i.content,"\n\n"])
  501.         description = "".join([description,p[-1:].pop().content])
  502.         self.__description = description
  503.         
  504.         # getting tile (summary) of the bugreport
  505.         title = self.__xml.xpathEval('//title')
  506.         parse_error(title, "BugReport.title", self.__xml, self.__url)
  507.         titleFilter = 'Bug #[0-9]* in ([^:]*?): (.*)'
  508.         title = re.findall(titleFilter, title[0].content)
  509.         parse_error(title, "BugReport.__title", url=self.__url)
  510.         self.__title = title[0][1].rstrip('\xe2\x80\x9d').lstrip('\xe2\x80\x9c')
  511.         
  512.         # getting target and sourcepackage
  513.         target = title[0][0].split(" ")
  514.         if len(target) == 2:
  515.             self.target = target[1].lstrip("(").rstrip(")")
  516.         self.sourcepackage = target[0]
  517.         # fix sourcepackage
  518.         if self.sourcepackage == 'Ubuntu':
  519.             self.sourcepackage = None
  520.         
  521.         # getting tags of bugreport
  522.         tags = self.__xml.xpathEval('//body//div[@class="report"]//\
  523. div[@id="bug-tags"]//a')
  524.         self.__tags = [i.content for i in tags]
  525.  
  526.         
  527.         # (thekorn) 20080828: design on edge changed
  528.         # edge and stable have significantly diverged
  529.         # if they are in sync again this conditional construct can
  530.         # be removed again
  531.         # affected: nickname, date, reporter
  532.         m = self.__xml.xpathEval('//span[@class="object identifier"]') #stable
  533.         m = m or self.__xml.xpathEval('//div[@class="object identifier"]') #edge
  534.         parse_error(m, "BugReport.__nickname", xml=self.__xml, url=self.__url)
  535.         r = re.search(r'\(([^\)]+)\)', m[0].content)
  536.         self.__nickname = (r and r.group(1)) or None
  537.         
  538.         d = self.__xml.xpathEval('//span[@class="object timestamp"]/span') #stable
  539.         d = d or self.__xml.xpathEval('//p[@class="object timestamp"]/span') #edge
  540.         parse_error(d, "BugReport.date", xml=m[0], url=self.__url)
  541.         self.date = LPTime(d[0].prop("title"))
  542.         
  543.         
  544.         d = self.__xml.xpathEval('//span[@class="object timestamp"]/a') #stable
  545.         d = d or self.__xml.xpathEval('//p[@class="object timestamp"]/a') #edge
  546.         parse_error(d, "BugReport.reporter", xml=m[0], url=self.__url)
  547.         self.reporter = user.parse_html_user(d[0])
  548.         
  549.         self.__cache = {"title": self.__title, "description" : self.__description,
  550.                             "tags" : self.__tags[:], "nickname" : self.__nickname}
  551.         self.parsed = True
  552.         return True
  553.         
  554.     def get_title(self):
  555.         return self.__title
  556.  
  557.     def set_title(self, title):
  558.         self.__title = title
  559.     title = property(get_title, set_title, doc="title of a bugreport")
  560.          
  561.     def get_description(self):
  562.         return self.__description
  563.  
  564.     def set_description(self, description):
  565.         self.__description = description
  566.     description = property(get_description, set_description,
  567.                     doc="description of a bugreport")
  568.  
  569.     @property
  570.     def tags(self):
  571.         return self.__tags
  572.          
  573.     def get_nickname(self):
  574.         return self.__nickname
  575.  
  576.     def set_nickname(self, nickname):
  577.         self.__nickname = nickname
  578.     nickname = property(get_nickname, set_nickname,
  579.                     doc="nickname of a bugreport")
  580.     
  581.     @property
  582.     def changed(self):
  583.         changed = set()
  584.         for k in self.__cache:
  585.             if self.__cache[k] != getattr(self, k):
  586.                 changed.add(change_obj(k))
  587.         return frozenset(changed)
  588.         
  589.     @property
  590.     def description_raw(self):
  591.         if not self.__description_raw:
  592.             url = "%s/+edit" %self.__url
  593.             result = self.__connection.get(url)
  594.             xmldoc = libxml2.htmlParseDoc(unicode_for_libxml2(result.text), "UTF-8")
  595.             x = xmldoc.xpathEval('//textarea[@name="field.description"]')
  596.             parse_error(x, "BugReport.description_raw", xml=xmldoc, url=url)
  597.             self.__description_raw = x[0].content
  598.         return self.__description_raw
  599.         
  600.     def commit(self, force_changes=False, ignore_lp_errors=True):
  601.         """ Commits the local changes to launchpad.net
  602.         
  603.         * force_changes: if a user adds a tag which has not been used before
  604.             and force_changes is True then commit() tries to create a new
  605.             tag for this package; if this fails or force_changes=False
  606.             commit will raise a 'ValueError'
  607.         * ignore_lp_errors: if the user tries to commit invalid data to launchpad,
  608.             launchpad returns an error-page. If 'ignore_lp_errors=False' Info.commit()
  609.             will raise an 'ValueError' in this case, otherwise ignore this
  610.             and leave the bugreport in launchpad unchanged (default=True)
  611.         """
  612.         if self.changed:
  613.             if "description" not in [i.component for i in self.changed]:
  614.                 description = self.description_raw
  615.             else:
  616.                 description = self.__description
  617.             if "nickname" not in [i.component for i in self.changed]:
  618.                 nickname = ""
  619.             else:
  620.                 nickname = self.nickname
  621.             if not (self.title and description):
  622.                 raise exceptions.PythonLaunchpadBugsValueError(msg="To change a bugreport 'description' \
  623. and 'title' don't have to be empty", url=self.__url)
  624.             args = { 'field.actions.change': '1', 
  625.                  'field.title': self.title, 
  626.                  'field.description': description, 
  627.                  'field.tags': ' '.join(self.tags),
  628.                  'field.name': nickname
  629.                   }
  630.             url = "%s/+edit" %self.__url
  631.             result = self.__connection.post(url, args)
  632.             
  633.             if result.url == url and (not ignore_lp_errors or force_changes):
  634.                 x = libxml2.htmlParseDoc(result.text, "UTF-8")
  635.                 # informational message - 'new tag'
  636.                 if x.xpathEval('//p[@class="informational message"]/input[@name="field.actions.confirm_tag"]'):
  637.                     if force_changes:
  638.                         # commit new tag
  639.                         args['field.actions.confirm_tag'] = '1'
  640.                         url = "%s/+edit" %self.__url
  641.                         result = self.__connection.post(url, args)
  642.                     else:
  643.                         raise ValueError, "Can't set 'tag', because it has not yet been used before."
  644.                 y = x.xpathEval('//p[@class="error message"]')
  645.                 if y:
  646.                     raise ValueError, "launchpad.net error: %s" %y[0].content
  647.  
  648.  
  649. class Attachment(LPAttachment):
  650.     """ Returns an 'Attachment'-object
  651.     
  652.     * editable attributes:
  653.         .description: any text
  654.         .contenttype: any text
  655.         .is_patch: True/False
  656.         
  657.     * read-only attributes:
  658.         .id: hash(local_filename) for local files,
  659.             launchpadlibrarian-id for files uploaded to launchpadlibrarian.net
  660.         .is_down: True if a file is downloaded to ATTACHMENT_PATH
  661.         .is_up: True if file is uploaded to launchpadlibrarian.net
  662.         ...
  663.     TODO: work on docstring
  664.     """
  665.     def __init__(self, connection, url=None, localfilename=None, localfileobject=None,
  666.                         description=None, is_patch=None, contenttype=None, comment=None):
  667.         LPAttachment.__init__(self, connection, url, localfilename,
  668.                                 localfileobject, description, is_patch,
  669.                                 contenttype, comment)
  670.             
  671.     def get_bugnumber(self):
  672.         if self.is_up:
  673.             return self.edit_url.split("/")[-3]
  674.                     
  675.     def get_sourcepackage(self):
  676.         if self.is_up:
  677.             return self.edit_url.split("/")[-5]
  678.             
  679.     def get_edit_url(self):
  680.         if self.is_up:
  681.             return valid_lp_url(self._edit, BASEURL.BUG)
  682.         
  683.  
  684. class Attachments(LPAttachments):
  685.     def __init__(self, comments, connection, xml):
  686.         LPAttachments.__init__(self, comments=comments)
  687.         self.__xml = xml
  688.         self.__connection = connection
  689.         self.__comments = comments
  690.         
  691.     def parse(self):
  692.         super(Attachments, self).parse()
  693.         if self.__xml:
  694.             attachments = self.__xml[0].xpathEval('li[@class="download"]')
  695.             all_att = {}
  696.             for a in attachments:
  697.                 url = a.xpathEval('a')[0].prop("href")
  698.                 edit = a.xpathEval('small/a')[0].prop("href")
  699.                 all_att[url] = edit
  700.             for i in self:
  701.                 i._edit = all_att.get(i.url, None)
  702.                 if not i._edit and i.is_up:
  703.                     parse_error(False, "Attachments.edit.1",
  704.                                     msg="There is an attachment (id=%s) which is added to a comment but does not appear in the sidepanel ('%s')" %(i.id, self.__comments._url),
  705.                                     error_type=exceptions.RUNTIMEERROR)
  706.         else:
  707.             parse_error(not self._current, "Attachments.edit.2",
  708.                             msg="Unable to parse the 'attachments' sidepanel although there are files attached to comments ('%s')" %self.__comments._url,
  709.                             error_type=exceptions.RUNTIMEERROR)
  710.             
  711.                 
  712.     def commit(self, force_changes=True, ignore_lp_errors=False, com_subject=None):
  713.         """
  714.             when adding a new attachment, this attachment is added as a new comment.
  715.             this new comment has no subject but a subject is required.
  716.             setting
  717.                 force_changes=True and ignore_lp_errors=False
  718.             results in adding a subject like:
  719.                 "Re: <bug summary>"
  720.         """
  721.         # nested functions:
  722.         def _lp_edit(attachment):
  723.             args = {'field.actions.change': '1', 'field.title': attachment.description,
  724.                     'field.patch': attachment.is_patch and 'on' or 'off', 'field.contenttype': attachment.contenttype or "text/plain"}
  725.             self.__connection.post("%s/+edit" %attachment.edit_url, args)
  726.         def _lp_delete(attachment):
  727.             args = {'field.actions.delete': '1', 'field.title': attachment.description,
  728.                     'field.patch': 'off', 'field.contenttype': 'text/plain'}
  729.             self.__connection.post("%s/+edit" %attachment.edit_url, args)
  730.             
  731.         def _lp_add(attachment):
  732.             """ delegated to comments """
  733.             assert isinstance(attachment, Attachment), "<attachment> has to be an instance of 'Attachment'"
  734.             c = Comment(attachment=(attachment,))
  735.             # delegate to Comments
  736.             self.__comments._lp_add_comment(c, force_changes, ignore_lp_errors, com_subject)
  737.             
  738.         changed = set(self.changed)
  739.         for i in changed:
  740.             if i.action == "added":
  741.                 _lp_add(i.component)
  742.             elif i.action == "deleted":
  743.                 _lp_delete(i.component)
  744.             elif i.action == "changed":
  745.                 _lp_edit(i.component)
  746.             else:
  747.                 raise AttributeError, "Unknown action '%s' in Attachments.commit()" %i.component
  748.  
  749.  
  750. class Comment(LPComment):
  751.     def __init__(self, subject=None, text=None, attachment=None):
  752.         LPComment.__init__(self, subject, text, attachment)
  753.  
  754.  
  755. class Comments(LPComments):
  756.     
  757.     def __init__(self, connection, xml, url):        
  758.         LPComments.__init__(self, url=url)
  759.         self.__xml = xml
  760.         self.__connection = connection
  761.         self.__url = url
  762.         
  763.     def parse(self):
  764.         for com in self.__xml:
  765.             m = com.xpathEval('div[@class="boardCommentDetails"]/a[1]')
  766.             parse_error(m, "Comments.user", xml=self.__xml, url=self.__url)
  767.             com_user = user.parse_html_user(m[0])
  768.  
  769.             m = com.xpathEval('div[@class="boardCommentDetails"]/a[2]')
  770.             parse_error(m, "Comments.nr", xml=self.__xml, url=self.__url)
  771.             com_nr = m[0].prop('href').split('/')[-1]
  772.  
  773.             m = com.xpathEval('div[@class="boardCommentDetails"]/span')
  774.             parse_error(m, "Comments.date", xml=self.__xml, url=self.__url)
  775.             com_date = LPTime(m[0].prop('title'))
  776.             
  777.             #optional subject
  778.             m = com.xpathEval('div[@class="boardCommentDetails"]/a[2]/strong')
  779.             if m:
  780.                 com_subject = m[0].content
  781.             else:
  782.                 com_subject = None
  783.             
  784.             m = com.xpathEval('div[@class="boardCommentBody"]/div')
  785.             parse_error(m, "Comments.text", xml=self.__xml, url=self.__url)
  786.             com_text = m[0].content
  787.             
  788.             com_attachments = set()
  789.             m = com.xpathEval('div[@class="boardCommentBody"]/ul/li')
  790.             for a in m:
  791.                 a_url = a.xpathEval("a").pop()
  792.                 a = re.search(r",\n +(\S+)(;|\))", a.content)
  793.                 parse_error(a, "Comments.attachment.re.%s" %a_url.prop('href'), xml=com, url=self.__url)
  794.                 a_contenttype = a.group(1)
  795.                 com_attachments.add(Attachment(self.__connection, url=a_url.prop('href'),
  796.                     description=a_url.content, comment=com_nr, contenttype=a_contenttype))
  797.                 
  798.             c = Comment(com_subject, com_text, com_attachments)
  799.             c.set_attr(nr=com_nr,user=com_user,date=com_date)
  800.             self.add(c)
  801.         self._cache = self[:]
  802.         self.parsed = True
  803.         return True
  804.         
  805.     def new(self, subject=None, text=None, attachment=None):
  806.         return Comment(subject, text, attachment, all_attachments=self._attachments)
  807.         
  808.     @property
  809.     def _url(self):
  810.         return self.__url
  811.         
  812.                 
  813.     def _lp_add_comment(self, comment, force_changes, ignore_lp_errors, com_subject):
  814.         assert isinstance(comment, Comment)
  815.         args = {
  816.             'field.subject': comment.subject or com_subject or "Re:",
  817.             'field.comment': comment.text or "", 
  818.             'field.actions.save': '1',
  819.             'field.filecontent.used': '',
  820.             'field.email_me.used': ''
  821.                 }
  822.         if comment.attachments:
  823.             # attachment is always a list, currently only 1 attachment per new comment supported
  824.             assert isinstance(comment.attachments, set), "comment.attachments has to be a set()"
  825.             assert len(comment.attachments) == 1, "currently LP only supports uploading of one comment at a time"
  826.             ca = list(comment.attachments)[0]
  827.             assert isinstance(ca, Attachment), "the file added to a comment has to be an instance of 'Attachment'"
  828.             assert ca.description, "each attachment needs al east a description"
  829.             args['field.patch.used'] = ''
  830.             args['field.filecontent.used'] = ''
  831.             args['field.filecontent'] = ca.local_fileobject
  832.             args['field.attachment_description'] = ca.description or ""
  833.             args['field.patch'] = ca.is_patch and 'on' or 'off'
  834.          
  835.         # print args #DEBUG
  836.         url = self.__url + '/+addcomment'
  837.         result = self.__connection.post(url, args)
  838.         # print result.url #DEBUG
  839.         if result.url == url and not ignore_lp_errors:# or force_changes):
  840.             # print "check result" #DEBUG
  841.             x = libxml2.htmlParseDoc(result.text, "UTF-8")
  842.             y = x.xpathEval('//p[@class="error message"]')
  843.             if y:
  844.                 if force_changes:
  845.                     z = x.xpathEval('//input[@name="field.subject" and @value=""]')
  846.                     if z:
  847.                         # print "has no 'subject' - add one!" #DEBUG
  848.                         z = x.xpathEval('//div[@class="pageheading"]/div')
  849.                         comment.subject = "Re: %s" %z[0].content.split("\n")[-2].lstrip().rstrip()
  850.                         self._lp_add_comment(comment, False, ignore_lp_errors)
  851.                 else:
  852.                     # print result.text
  853.                     raise ValueError, "launchpad.net error: %s" %y[0].content
  854.         return result
  855.             
  856.     def commit(self, force_changes=False, ignore_lp_errors=True, com_subject=None):
  857.         for i in self.changed:
  858.             if i.action == "added":
  859.                 self._lp_add_comment(i.component, force_changes, ignore_lp_errors, com_subject)
  860.             else:
  861.                 raise AttributeError, "Unknown action '%s' in Comments.commit()" %i.component
  862.  
  863.  
  864. class Duplicates(object):
  865.     def __init__(self, connection, xml, url):
  866.         self.parsed = False
  867.         self.__cache = None
  868.         self.__connection=connection
  869.         self.__xml = xml
  870.         self.__url = url
  871.         [self.__duplicate_of, self.__duplicates] = [None]*2
  872.         
  873.     def __repr__(self):
  874.         return "<Duplicates>"
  875.         
  876.     def parse(self):
  877.         if self.parsed:
  878.             return True
  879.         # Check if this bug is already marked as a duplicate
  880.         nodes = self.__xml.xpathEval('//body//a[@id="duplicate-of"]')
  881.         if len(nodes)>0:
  882.             self.__duplicate_of = int(nodes[0].prop('href').split('/').pop())
  883.         result = self.__xml.xpathEval('//body//div[@class="portlet"]/h2[contains(.,"Duplicates of this bug")]/../div[@class="portletBody"]/div/ul//li/a')
  884.         self.__duplicates = set([int(i.prop("href").split('/')[-1]) for i in result])
  885.         self.__cache = self.__duplicate_of
  886.         self.parsed = True
  887.         return True
  888.         
  889.     def get_duplicates(self):
  890.         return self.__duplicates
  891.     duplicates = property(get_duplicates, doc="get a list of duplicates")
  892.     
  893.     def get_duplicate_of(self):
  894.         return self.__duplicate_of
  895.         
  896.     def set_duplicate_of(self, bugnumber):
  897.         if bugnumber == None:
  898.             self.__duplicate_of = None
  899.         else:
  900.             self.__duplicate_of = int(bugnumber)
  901.     duplicate_of = property(get_duplicate_of, set_duplicate_of, doc="this bug report is duplicate of")
  902.     
  903.     @property
  904.     def changed(self):
  905.         __changed = set()
  906.         if self.__cache != self.__duplicate_of:
  907.             __changed.add("duplicate_of")
  908.         return frozenset(__changed)
  909.         
  910.         
  911.     def commit(self, force_changes=False, ignore_lp_errors=True):
  912.         if self.changed:
  913.             args = { 'field.actions.change': '1', 
  914.                      'field.duplicateof': self.__duplicate_of or ""
  915.                     }
  916.             url = "%s/+duplicate" %self.__url
  917.             result = self.__connection.post(url, args)
  918.             
  919.             if result.url == url and not ignore_lp_errors:# or force_changes):
  920.                 x = libxml2.htmlParseDoc(result.text, "UTF-8")
  921.                 # informational message - 'new tag'
  922.                 y = x.xpathEval('//p[@class="error message"]')
  923.                 if y:
  924.                     raise ValueError, "launchpad.net error: %s" %y[0].content
  925.             
  926.     
  927. class Secrecy(object):
  928.     def __init__(self, connection, xml, url):
  929.         self.parsed = False
  930.         self.__cache = set()
  931.         self.__connection=connection
  932.         self.__xml = xml
  933.         self.__url = url
  934.         [self.__security, self.__private] = [False]*2
  935.         
  936.     def __repr__(self):
  937.         return "<Secrecy>"
  938.         
  939.     def parse(self):
  940.         if self.parsed:
  941.             return True
  942.         # (thekorn) 20080711: design on edge changed
  943.         # edge and stable have significantly diverged
  944.         # if they are in sync again this conditional construct can
  945.         # be removed again
  946.         #~ this error check makes no sense
  947.         #~ parse_error(self.__xml, "Secrecy.__xml", xml=self.__xml, url=self.__url)
  948.         stable_xml = self.__xml.xpathEval('//body//div[@id="big-badges"]')
  949.         if stable_xml:
  950.             if stable_xml[0].xpathEval('img[@alt="(Security vulnerability)"]'):
  951.                 self.__security = True
  952.             if stable_xml[0].xpathEval('img[@alt="(Private)"]'):
  953.                 self.__private = True
  954.         else:
  955.             self.__private = bool(self.__xml.xpathEval('//a[contains(@href, "+secrecy")]/strong'))
  956.             self.__security = bool(self.__xml.xpathEval('//div[contains(@style, "/@@/security")]'))
  957.         self.__cache = {"security": self.__security, "private" : self.__private}
  958.         self.parsed = True
  959.         return True
  960.             
  961.     def _editlock(self):
  962.         return bool(get_bug(id(self)).duplicate_of)
  963.             
  964.     def get_security(self):
  965.         assert self.parsed, "parse first"
  966.         return self.__security
  967.     
  968.     def set_security(self, security):
  969.         # if self._editlock():
  970.         #    raise IOError, "'security' of this bug can't be edited, maybe because this bug is a duplicate of an other one"
  971.         self.__security = bool(security)
  972.     security = property(get_security, set_security, doc="security status")
  973.  
  974.     def get_private(self):
  975.         assert self.parsed, "parse first"
  976.         return self.__private
  977.     
  978.     def set_private(self, private):
  979.         # if self._editlock():
  980.         #    raise IOError, "'private' of this bug can't be edited, maybe because this bug is a duplicate of an other one"
  981.         self.__private = bool(private)
  982.     private = property(get_private, set_private, doc="private status")
  983.  
  984.     def get_changed(self):
  985.         __changed = set()
  986.         for k in self.__cache:
  987.             if self.__cache[k] != getattr(self, k):
  988.                 __changed.add(k)
  989.         return frozenset(__changed)
  990.     changed = property(get_changed, doc="get a list of changed attributes")
  991.         
  992.         
  993.     def commit(self, force_changes=False, ignore_lp_errors=True):
  994.         __url = "%s/+secrecy" %self.__url
  995.         status = ["off", "on"]
  996.         if self.changed:
  997.             __args = { 'field.private': status[int(self.private)],
  998.                  'field.security_related': status[int(self.security)],
  999.                  'field.actions.change': 'Change'
  1000.                }
  1001.             __result = self.__connection.post(__url, __args)
  1002.  
  1003.  
  1004. class Subscribers(LPSubscribers):
  1005.     """ TODO:
  1006.         * change structure: use three different sets instead of one big one
  1007.     """
  1008.     def __init__(self, connection, xml, url):
  1009.         self.parsed = False
  1010.         self.__connection=connection
  1011.         self.__xml = xml
  1012.         self.__url = url
  1013.         LPSubscribers.__init__(self, ("directly", "notified", "duplicates"))
  1014.         
  1015.     def parse(self):
  1016.         
  1017.         if self.parsed:
  1018.             return True
  1019.         parse_error(self.__xml, "Subscribers.__xml", xml=self.__xml, url=self.__url)
  1020.         xml = self.__xml[0].xpathEval('div[(@class="section" or @class="Section") and @id]') #fix for v6739
  1021.         xml_YUI = self.__xml[0].xpathEval('script[@type="text/javascript"]')
  1022.         if xml_YUI and not xml:
  1023.             bugnumber = int(self.__url.split("/")[-1])
  1024.             url = "https://launchpad.net/bugs/%i/+bug-portlet-subscribers-content" %bugnumber
  1025.             page = self.__connection.get(url)
  1026.             ctx = libxml2.htmlParseDoc(unicode_for_libxml2(page.text), "UTF-8")
  1027.             xml = ctx.xpathEval('//div[(@class="section" or @class="Section") and @id]')
  1028.         if xml:
  1029.             sections_map = {"subscribers-direct": "directly",
  1030.                             "subscribers-indirect": "notified",
  1031.                             "subscribers-from-duplicates": "duplicates",
  1032.                             }
  1033.             for s in xml:
  1034.                 kind = sections_map[s.prop("id")]
  1035.                 nodes = s.xpathEval("div/a")
  1036.                 for i in nodes:
  1037.                     self[kind].add(user.parse_html_user(i))            
  1038.         else:
  1039.             if xml_YUI:
  1040.                 n = ".YUI"
  1041.             else:
  1042.                 n = ""
  1043.             parse_error(False, "Subscribers.__xml.edge.stable%s" %n, xml=self.__xml, url=self.__url)
  1044.         self.parsed = True
  1045.         return True
  1046.                 
  1047.     def commit(self, force_changes=False, ignore_lp_errors=True):
  1048.         x = self.changed
  1049.         if x:
  1050.             for i in [a.component for a in x if a.action == "removed"]:
  1051.                 self._remove(i)
  1052.             for i in [a.component for a in x if a.action == "added"]:
  1053.                 self._add(i)
  1054.         
  1055.     def _add(self, lplogin):
  1056.         '''Add a subscriber to a bug.'''
  1057.         url = "%s/+addsubscriber" %self.__url
  1058.         args = { 'field.person': lplogin, 
  1059.                  'field.actions.add': 'Add'
  1060.                }
  1061.         result = self.__connection.post(url, args)
  1062.         if result.url == url:
  1063.             x = libxml2.htmlParseDoc(result.text, "UTF-8")
  1064.             if x.xpathEval('//div[@class="message" and contains(.,"Invalid value")]'):
  1065.                 raise ValueError, "Unknown Launchpad ID. You can only subscribe someone who has a Launchpad account."
  1066.             else:
  1067.                 raise ValueError, "Unknown error while subscribe %s to %s" %(lplogin, url)
  1068.         return result
  1069.         
  1070.     def _remove(self, lplogin):
  1071.         '''Remove a subscriber from a bug.'''
  1072.         url = "%s/" %self.__url
  1073.         args = { 'field.subscription': lplogin, 
  1074.                  'unsubscribe': 'Continue'
  1075.                }
  1076.         result = self.__connection.post(url, args)
  1077.         return result
  1078.         
  1079.  
  1080. class ActivityWhat(str):
  1081.     def __new__(cls, what, url=None):
  1082.         obj = super(ActivityWhat, cls).__new__(ActivityWhat, what)
  1083.         obj.__task = None
  1084.         obj.__attribute = None
  1085.         x = what.split(":")
  1086.         if len(x) == 2:
  1087.             obj.__task = x[0]
  1088.             obj.__attribute = x[1].strip()
  1089.         elif len(x) == 1:
  1090.             obj.__attribute = x[0]
  1091.         else:
  1092.             raise ValueError
  1093.         return obj
  1094.         
  1095.     @property
  1096.     def task(self):
  1097.         return self.__task
  1098.         
  1099.     @property
  1100.     def attribute(self):
  1101.         return self.__attribute
  1102.  
  1103.  
  1104.      
  1105. class Activity(object):
  1106.     def __init__(self, date, user, what, old_value, new_value, message):
  1107.         self.__date = date
  1108.         self.__user = user
  1109.         self.__what = what
  1110.         self.__old_value = old_value
  1111.         self.__new_value = new_value
  1112.         self.__message = message
  1113.         
  1114.     def __repr__(self):
  1115.         return "<%s %s '%s'>" %(self.user, self.date, self.what)        
  1116.         
  1117.     @property
  1118.     def date(self):
  1119.         return self.__date
  1120.         
  1121.     @property
  1122.     def user(self):
  1123.         return self.__user
  1124.         
  1125.     @property
  1126.     def what(self):
  1127.         return self.__what
  1128.         
  1129.     @property
  1130.     def old_value(self):
  1131.         return self.__old_value
  1132.         
  1133.     @property
  1134.     def new_value(self):
  1135.         return self.__new_value
  1136.         
  1137.     @property
  1138.     def message(self):
  1139.         return self.__message
  1140.         
  1141.         
  1142. class ActivityLog(object):
  1143.     """
  1144.     TODO: there is nor clear relation between an entry in the activity log
  1145.         and a certain task, this is why the result of when(), completed(),
  1146.         assigned() and started_work() may differ from the grey infobox added
  1147.         to each task. Maybe we should also parse this box.
  1148.     """
  1149.     def __init__(self, connection, url):
  1150.         self.parsed = False
  1151.         self.__connection=connection  
  1152.         self.__activity = []
  1153.         self.__url = url
  1154.         
  1155.     def __repr__(self):
  1156.         return "<activity log>"
  1157.         
  1158.     def __str__(self):
  1159.         return str(self.__activity)
  1160.         
  1161.     def __iter__(self):
  1162.         for i in self.activity:
  1163.             yield i
  1164.             
  1165.     def __getitem__(self, key):
  1166.         return self.activity[key]
  1167.         
  1168.     def __len__(self):
  1169.         return len(self.activity)
  1170.         
  1171.     @property
  1172.     def activity(self):
  1173.         if not self.parsed:
  1174.             self.parse()
  1175.         return self.__activity
  1176.         
  1177.     def _activity_rev(self):
  1178.         if not self.parsed:
  1179.             self.parse()
  1180.         return self.__activity_rev
  1181.         
  1182.     def parse(self):
  1183.         if self.parsed:
  1184.             return True
  1185.         page = self.__connection.get("%s/+activity" %self.__url)
  1186.         
  1187.         self.__xmldoc = libxml2.htmlParseDoc(unicode_for_libxml2(page.text), "UTF-8")
  1188.         table = self.__xmldoc.xpathEval('//body//table[@class="listing"][1]//tbody//tr')
  1189.         parse_error(table, "ActivityLog.__table", xml=self.__xmldoc, url=self.__url)
  1190.         for row in table:
  1191.             r = row.xpathEval("td")
  1192.             parse_error(len(r) == 6, "ActivityLog.len(td)", xml=row, url=self.__url)
  1193.             
  1194.             date = LPTime(r[0].content)
  1195.             x = r[1].xpathEval("a")
  1196.             parse_error(x, "ActivityLog.lp_user", xml=row, url=self.__url)
  1197.             lp_user = user.parse_html_user(x[0])
  1198.             try:
  1199.                 what = ActivityWhat(r[2].content)
  1200.             except ValueError:
  1201.                 parse_error(False, "ActivityLog.ActivityWhat", xml=self.__xmldoc, url="%s/+activity" %self.__url)
  1202.             old_value = r[3].content
  1203.             new_value = r[4].content
  1204.             message = r[5].content
  1205.             
  1206.             self.__activity.append(Activity(date, lp_user, what,
  1207.                                         old_value, new_value, message))
  1208.         self.__activity_rev = self.__activity[::-1]
  1209.         self.parsed = True
  1210.         return True
  1211.         
  1212.     def assigned(self, task):
  1213.         for i in self._activity_rev():
  1214.             if i.what.task == task and i.what.attribute == "assignee":
  1215.                 return i.date
  1216.                 
  1217.     def completed(self, task):
  1218.         for i in self._activity_rev():
  1219.             if i.what.task == task and i.what.attribute == "status" and i.new_value in ["Invalid", "Fix Released"]:
  1220.                 return i.date
  1221.                 
  1222.     def when(self, task):
  1223.         for i in self._activity_rev():
  1224.             if i.what == "bug" and i.message.startswith("assigned to") and i.message.count(task):
  1225.                 return i.date
  1226.                 
  1227.     def started_work(self, task):
  1228.         for i in self._activity_rev():
  1229.             if i.what.task == task and i.what.attribute == "status" and i.new_value in ["In Progress", "Fix Committed"]:
  1230.                 return i.date
  1231.             
  1232.                 
  1233. class Mentoring(object):
  1234.     def __init__(self, connection, xml, url):
  1235.         self.parsed = False
  1236.         self.__cache = set()
  1237.         self.__connection=connection
  1238.         self.__xml = xml
  1239.         self.__url = url
  1240.         self.__mentor = set()
  1241.         
  1242.     def __repr__(self):
  1243.         return "<Mentor for #%s>" %self.__url
  1244.         
  1245.     def parse(self):
  1246.         if self.parsed:
  1247.             return True
  1248.         for i in self.__xml:
  1249.             self.__mentor.add(user.parse_html_user(i))
  1250.         self.__cache = self.__mentor.copy()
  1251.         self.parsed = True
  1252.         return True
  1253.         
  1254.     @property
  1255.     def mentor(self):
  1256.         return self.__mentor
  1257.         
  1258.     @property
  1259.     def changed(self):
  1260.         """get a list of changed attributes
  1261.         currently read-only
  1262.         """
  1263.         return set()
  1264.         
  1265.     def commit(self, force_changes=False, ignore_lp_errors=True):
  1266.         raise NotImplementedError, 'this method is not implemented ATM'
  1267.         
  1268.         
  1269. class BzrBranch(object):
  1270.     def __init__(self, title, url, status):
  1271.         self.__title = title
  1272.         self.__url = url
  1273.         self.__status = status
  1274.         
  1275.     def __repr__(self):
  1276.         return "BzrBranch(%s, %s, %s)" %(self.title, self.url, self.status)
  1277.         
  1278.     __str__ = __repr__
  1279.             
  1280.     @property
  1281.     def title(self):
  1282.         return self.__title
  1283.         
  1284.     @property
  1285.     def url(self):
  1286.         return self.__url
  1287.         
  1288.     @property
  1289.     def status(self):
  1290.         return self.__status
  1291.         
  1292.         
  1293.         
  1294. class Branches(set):
  1295.     def __init__(self, connection, xml, url):
  1296.         self.parsed = False
  1297.         set.__init__(self)
  1298.         self.__url = url
  1299.         self.__xml = xml
  1300.         self.__connection = connection
  1301.         
  1302.     def parse(self):
  1303.         if self.parsed:
  1304.             return True
  1305.         for i in self.__xml:
  1306.             m = i.xpathEval("a[1]")
  1307.             assert m
  1308.             title = m[0].prop("title")
  1309.             url = m[0].prop("href")
  1310.             m = i.xpathEval("span")
  1311.             assert m
  1312.             status = m[0].content
  1313.             self.add(BzrBranch(title, url, status))
  1314.         self.parsed = True
  1315.         
  1316.     @property
  1317.     def changed(self):
  1318.         """get a list of changed attributes
  1319.         currently read-only
  1320.         """
  1321.         return set()
  1322.         
  1323.     def commit(self, force_changes=False, ignore_lp_errors=True):
  1324.         raise NotImplementedError, 'this method is not implemented ATM'
  1325.  
  1326.  
  1327. class Bug(BugBase):
  1328.     _container_refs = {}
  1329.  
  1330.     def __init__(self, bug=None, url=None, connection=None):
  1331.             
  1332.         BugBase.__init__(self, bug, url, connection)
  1333.  
  1334.         bugpage = self.__connection.get(self.__url)
  1335.  
  1336.         # self.text contains the whole HTML-formated Bug-Page
  1337.         self.__text = bugpage.text
  1338.         # Get the rewritten URL so that we have a valid one to attach comments
  1339.         # to
  1340.         self.__url = bugpage.url
  1341.         
  1342.         self.xmldoc = libxml2.htmlParseDoc(unicode_for_libxml2(self.__text), "UTF-8")
  1343.         
  1344.         self.__bugreport = BugReport(connection=self.__connection, xml=self.xmldoc, url=self.__url)
  1345.         self.__infotable = InfoTable(connection=self.__connection, xml=self.xmldoc.xpathEval('//body//table[@class="listing" or @class="duplicate listing"][1]'), url=self.__url)
  1346.         self.__comments = Comments(connection=self.__connection, xml=self.xmldoc.xpathEval('//body//div[normalize-space(@class)="boardComment"]'), url=self.__url)
  1347.         self.__attachments = Attachments(self.__comments, self.__connection, self.xmldoc.xpathEval('//body//div[@id="portlet-attachments"]/div/div/ul'))
  1348.         self.__duplicates = Duplicates(connection=self.__connection, xml=self.xmldoc, url=self.__url)
  1349.         self.__secrecy = Secrecy(connection=self.__connection, xml=self.xmldoc, url=self.__url)
  1350.         self.__subscribers = Subscribers(connection=self.__connection, xml=self.xmldoc.xpathEval('//body//div[@id="portlet-subscribers"]'), url=self.__url)
  1351.         self.__mentor = Mentoring(connection=self.__connection, xml=self.xmldoc.xpathEval('//body//img [@src="/@@/mentoring"]/parent::p//a'), url=self.__url)
  1352.         self.__activity = ActivityLog(connection=self.__connection, url=self.__url)
  1353.         self.__branches = Branches(self.__connection, self.xmldoc.xpathEval('//body//div[@class="bug-branch-summary"]'), self.__url)
  1354.         
  1355.         Bug._container_refs[id(self.__attachments)] = self
  1356.         Bug._container_refs[id(self.__comments)] = self
  1357.         Bug._container_refs[id(self.__secrecy)] = self
  1358.  
  1359.     def __del__(self):
  1360.         "run self.xmldoc.freeDoc() to clear memory"
  1361.         if hasattr(self, "xmldoc"):
  1362.             self.xmldoc.freeDoc()
  1363.         
  1364.     @property
  1365.     def changed(self):
  1366.         __result = []
  1367.         for i in filter(lambda a: a.startswith("_Bug__"),dir(self)):
  1368.             # just for Developing; later each object should have a 'changed' property
  1369.             try:
  1370.                 a = getattr(self.__dict__[i], "changed")
  1371.             except AttributeError:
  1372.                 continue
  1373.             if a:
  1374.                 __result.append(change_obj(self.__dict__[i]))
  1375.         return __result
  1376.         
  1377.     def commit(self, force_changes=False, ignore_lp_errors=True):
  1378.         for i in self.changed:
  1379.             if isinstance(i.component, Comments):
  1380.                 result = i.component.commit(force_changes, ignore_lp_errors, "Re: %s" %self.title)
  1381.             else:
  1382.                 result = i.component.commit(force_changes, ignore_lp_errors)
  1383.                         
  1384.     def revert(self):
  1385.         """ need a function to revert changes """
  1386.         pass
  1387.                 
  1388.             
  1389.         
  1390.        
  1391. # Overwrite the abstract functions in bugbase.Bug        
  1392.         
  1393.     # read-only
  1394.     
  1395.     get_url = _gen_getter("__url")
  1396.     get_bugnumber = _gen_getter("__bugnumber")
  1397.     
  1398.     get_reporter = _gen_getter("__bugreport.reporter")
  1399.     get_date = _gen_getter("__bugreport.date")
  1400.     get_duplicates = _gen_getter("__duplicates.duplicates")
  1401.     get_description_raw = _gen_getter("__bugreport.description_raw")
  1402.     get_activity = _gen_getter("__activity")
  1403.         
  1404.     def get_text(self):
  1405.         return "%s\n%s" %(self.description,"\n".join([c.text for c in self.comments]))
  1406.     
  1407.     #...
  1408.     
  1409.     # +edit
  1410.  
  1411.     get_title = _gen_getter("__bugreport.title")
  1412.     get_description = _gen_getter("__bugreport.description")
  1413.     set_description = _gen_setter("__bugreport.description")
  1414.     get_tags = _gen_getter("__bugreport.tags")
  1415.     get_nickname = _gen_getter("__bugreport.nickname")
  1416.     
  1417.     #...
  1418.     
  1419.     #+mentoring
  1420.     
  1421.     get_mentors = _gen_getter("__mentor.mentor")
  1422.  
  1423.     # +editstatus
  1424.  
  1425.     get_infotable = _gen_getter("__infotable")
  1426.     get_info = _blocked(_gen_getter("__infotable.current"), "No 'current' available.")
  1427.     get_target = _blocked(_gen_getter("__infotable.current.target"), "Can't get 'target'.")
  1428.     get_importance = _blocked(_gen_getter("__infotable.current.importance"), "Can't get 'importance'.")
  1429.     set_importance = _blocked(_gen_setter("__infotable.current.importance"), "Can't set 'importance'.")
  1430.     get_status = _blocked(_gen_getter("__infotable.current.status"), "Can't get 'status'.")
  1431.     set_status = _blocked(_gen_setter("__infotable.current.status"), "Can't set 'status'.")
  1432.     get_assignee = _blocked(_gen_getter("__infotable.current.assignee"), "Can't get 'assignee'.")
  1433.     set_assignee = _blocked(_gen_setter("__infotable.current.assignee"), "Can't set 'assignee'.")
  1434.     get_milestone = _blocked(_gen_getter("__infotable.current.milestone"), "Can't get 'milestone'.")
  1435.     set_milestone = _blocked(_gen_setter("__infotable.current.milestone"), "Can't set 'milestone'.")
  1436.     get_sourcepackage = _blocked(_gen_getter("__infotable.current.sourcepackage"), "Can't get 'sourcepackage'.")
  1437.     set_sourcepackage = _blocked(_gen_setter("__infotable.current.sourcepackage"), "Can't set 'sourcepackage'.")
  1438.     get_affects = _blocked(_gen_getter("__infotable.current.affects"), "Can't get 'affects'.")
  1439.     
  1440.     def has_target(self, target):
  1441.         if not self.__infotable.parsed:
  1442.             self.__infotable.parse()
  1443.         return self.__infotable.has_target(target)
  1444.         
  1445.     # ...
  1446.     
  1447.     # +duplicate
  1448.  
  1449.     get_duplicates = _gen_getter("__duplicates.duplicates")
  1450.     get_duplicate = _gen_getter("__duplicates.duplicate_of")
  1451.     set_duplicate = _gen_setter("__duplicates.duplicate_of")
  1452.     
  1453.     #...
  1454.     
  1455.     # +secrecy
  1456.     
  1457.     get_security = _gen_getter("__secrecy.security")
  1458.     set_security = _gen_setter("__secrecy.security")
  1459.     get_private = _gen_getter("__secrecy.private")
  1460.     set_private = _gen_setter("__secrecy.private")
  1461.     
  1462.     #...
  1463.         
  1464.     # subscription
  1465.     
  1466.     get_subscriptions = _gen_getter("__subscribers")
  1467.     get_attachments = _gen_getter("__attachments")    
  1468.     def get_comments(self):
  1469.         x = self.attachments
  1470.         if not self.__comments.parsed:
  1471.             self.comments.parse()
  1472.         return self.__comments
  1473.  
  1474.     def get_subscriptions_category(self, type):
  1475.         """ get subscriptions for a given category, possible categories are "directly", "notified", "duplicates" """
  1476.         if not self.__subscribers.parsed:
  1477.             self.__subscribers.parse()
  1478.         return self.__subscribers.get_subscriptions(type)
  1479.         
  1480.     get_branches = _gen_getter("__branches")
  1481.      
  1482.     
  1483.     
  1484. def create_new_bugreport(product, summary, description, connection, tags=[], security_related=False):
  1485.     """ creates a new bugreport and returns its bug object
  1486.     
  1487.         product keys: "name", "target" (optional)
  1488.         tags: list of tags
  1489.     """
  1490.     
  1491.     args = {"field.title": summary,
  1492.             "field.comment": description,
  1493.             "field.actions.submit_bug": 1}
  1494.     if tags:
  1495.         args["field.tags"] = " ".join(tags)
  1496.     if security_related:
  1497.         args["field.security_related"] = "on"
  1498.     if product.has_key("target"):
  1499.         url = "https://bugs.launchpad.net/%s/+source/%s/+filebug-advanced" %(product["target"], product["name"])
  1500.         args["field.packagename"] = product["name"]
  1501.     else:
  1502.         url = "https://bugs.launchpad.net/%s/+filebug-advanced" %product["name"]
  1503.     
  1504.     try:
  1505.         result = connection.post(url, args)
  1506.     except exceptions.LaunchpadError, e:
  1507.         try:
  1508.             x = connection.needs_login()
  1509.         except exceptions.LaunchpadError:
  1510.             x = False
  1511.         if x:
  1512.             raise exceptions.LaunchpadLoginError(url)
  1513.         elif isinstance(e, exceptions.LaunchpadURLError):
  1514.             if "Page not found" in e.msg:
  1515.                 m = """Maybe there is no product '%s' in """ %product["name"]
  1516.                 if product.has_key("target"):
  1517.                     m += """the distribution '%s'""" %product["target"]
  1518.                 else:
  1519.                     m += "launchpad.net"
  1520.                 raise exceptions.PythonLaunchpadBugsValueError(msg=m)
  1521.         else:
  1522.             raise
  1523.             
  1524.     if not result.url.endswith("+filebug-advanced"):
  1525.         return Bug(url=result.url, connection=connection)
  1526.     else:
  1527.         raise exceptions.choose_pylpbugsError(error_type=exceptions.VALUEERROR, text=result.text, url=url)
  1528.