[FEATURE] Task manager #3004

Merged
merged 48 commits into from Dec 5, 2016

Conversation

Projects
None yet
@nyalldawson
Contributor

nyalldawson commented Apr 15, 2016

Prototype work in implementing a task manager for QGIS, with easy handling of submitting tasks to be run in the background and exposing their status to users.

Adds new classes:

  • QgsTask. An interface for long-running background tasks. Tasks must implement the run() method to do their processing, and periodically test for isCancelled() to gracefully abort.
  • QgsTaskManager. Handles groups of tasks - also available as a global instance for tracking application wide tasks. Adding a task to a manager will cause the manager to handle launching the run() method in a separate thread.
  • QgsTaskManagerWidget. A list view for showing active tasks and their progress, and for cancelling them

A new dock widget has been added with a task manager widget showing global tasks.

You can test with the following code:

from time import sleep
class TestTask( QgsTask ):

    def __init__(self, desc, time ):
        QgsTask.__init__(self, desc )
        self.time= time

    def run(self):
        wait_time = self.time / 100.0
        for i in range(101):
            sleep(wait_time)
            self.setProgress(i)
            if self.isCancelled():
                self.stopped()
                return 
        self.completed()

for time in range(10, 14):
    task = TestTask('wait {}'.format( time ), time ) 
    QgsTaskManager.instance().addTask( task )

Note that I get crashes with this. My suspicion is that it's due to some oddness in SIP, but it's quite possible there's bugs in the code too!

@wonder-sk Would appreciate you taking a look at this version!

@m-kuhn

This comment has been minimized.

Show comment
Hide comment
@m-kuhn

m-kuhn Apr 15, 2016

Member

Sounds interesting. What's the use case for this? Processing?

Member

m-kuhn commented Apr 15, 2016

Sounds interesting. What's the use case for this? Processing?

@alexbruy

This comment has been minimized.

Show comment
Hide comment
@alexbruy

alexbruy Apr 15, 2016

Contributor

For Processing we already implemented mutithreading support and background tasks. Now waiting for 3.0 branch as it breaks Processing API.

Contributor

alexbruy commented Apr 15, 2016

For Processing we already implemented mutithreading support and background tasks. Now waiting for 3.0 branch as it breaks Processing API.

@nyalldawson

This comment has been minimized.

Show comment
Hide comment
@nyalldawson

nyalldawson Apr 15, 2016

Contributor

A couple of use cases:

  • make a central place for managing and reporting on the progress of long running tasks. Eg I want to add background high resolution map exports.
  • make it easy for plugins to run tasks in the background without having to have their own thread handling
  • avoid multi implementations of task reporting (eg all background tasks are shown in a single place in the gui, rather than fragmented around different areas)

Ideally processing would use this too. @alexbruy does this implementation look compatible with the processing background tasks?

Contributor

nyalldawson commented Apr 15, 2016

A couple of use cases:

  • make a central place for managing and reporting on the progress of long running tasks. Eg I want to add background high resolution map exports.
  • make it easy for plugins to run tasks in the background without having to have their own thread handling
  • avoid multi implementations of task reporting (eg all background tasks are shown in a single place in the gui, rather than fragmented around different areas)

Ideally processing would use this too. @alexbruy does this implementation look compatible with the processing background tasks?

@nyalldawson

This comment has been minimized.

Show comment
Hide comment
@nyalldawson

nyalldawson Apr 16, 2016

Contributor

@wonder-sk There's something odd going on here. Eg, the following code

from time import sleep
from PyQt.QtCore import QObject

class TestTask( QgsTask ):

    def __init__(self, desc, time ):
        QgsTask.__init__(self, desc )
        self.time = time

    def run(self):
        wait_time = self.time / 100.0
        for i in range(101):
            sleep(wait_time)
            self.setProgress(i)
            if self.isCancelled():
                self.stopped()
                return 
        self.completed()

class MyPlugin( QObject ):

    def task_begun(self):
        print 'begun'

    def task_complete(self):
        print 'complete'

    def progress(self,val):
        print val

    def newTask(self):
        task = TestTask('wait {}'.format( 10 ), 10 ) 
        task.begun.connect(self.task_begun)
        task.taskCompleted.connect(self.task_complete)
        task.progressChanged.connect(self.progress)
        QgsTaskManager.instance().addTask( task )
m=MyPlugin()
m.newTask()

The TestTask.run method never actually gets called. I suspect there's something blocking this with PyQt and its handling of threads. https://riverbankcomputing.com/pipermail/pyqt/2011-February/029227.html makes me thing you may have hit this in the past... got any ideas?

Contributor

nyalldawson commented Apr 16, 2016

@wonder-sk There's something odd going on here. Eg, the following code

from time import sleep
from PyQt.QtCore import QObject

class TestTask( QgsTask ):

    def __init__(self, desc, time ):
        QgsTask.__init__(self, desc )
        self.time = time

    def run(self):
        wait_time = self.time / 100.0
        for i in range(101):
            sleep(wait_time)
            self.setProgress(i)
            if self.isCancelled():
                self.stopped()
                return 
        self.completed()

class MyPlugin( QObject ):

    def task_begun(self):
        print 'begun'

    def task_complete(self):
        print 'complete'

    def progress(self,val):
        print val

    def newTask(self):
        task = TestTask('wait {}'.format( 10 ), 10 ) 
        task.begun.connect(self.task_begun)
        task.taskCompleted.connect(self.task_complete)
        task.progressChanged.connect(self.progress)
        QgsTaskManager.instance().addTask( task )
m=MyPlugin()
m.newTask()

The TestTask.run method never actually gets called. I suspect there's something blocking this with PyQt and its handling of threads. https://riverbankcomputing.com/pipermail/pyqt/2011-February/029227.html makes me thing you may have hit this in the past... got any ideas?

@alexbruy

This comment has been minimized.

Show comment
Hide comment
@alexbruy

alexbruy Apr 18, 2016

Contributor

@nyalldawson we used similar approach. But seems we should drop our work and adopt Processing to use your task manager.

Contributor

alexbruy commented Apr 18, 2016

@nyalldawson we used similar approach. But seems we should drop our work and adopt Processing to use your task manager.

@nyalldawson

This comment has been minimized.

Show comment
Hide comment
@nyalldawson

nyalldawson Apr 18, 2016

Contributor

@wonder-sk you're troubleshooting was spot-on. It was related to the task objects getting overwritten in Python.

Here's a screencast of the Task Manager in action:
https://www.youtube.com/watch?v=3KzwNErzkJg&feature=youtu.be

This is using the sample code:

from time import sleep
from PyQt.QtCore import QObject

class TestTask( QgsTask ):

    def __init__(self, desc, time ):
        QgsTask.__init__(self, desc )
        self.time = time

    def run(self):
        wait_time = self.time / 100.0
        for i in range(101):
            sleep(wait_time)
            self.setProgress(i)
            if self.isCancelled():
                 self.stopped()
                 return
        self.completed()

class MyPlugin( QObject ):

    def task_begun(self):
        print '"{}" begun'.format( self.sender().description() )

    def task_complete(self):
        print '"{}" complete'.format( self.sender().description() )

    def task_stopped(self):
        print '"{}" cancelled!'.format( self.sender().description() )

    def progress(self,val):
        #print val
        pass

    def newTask(self, task_name, length):
        task = TestTask(task_name, length ) 
        task.begun.connect(self.task_begun)
        task.taskCompleted.connect(self.task_complete)
        task.progressChanged.connect(self.progress)
        task.taskStopped.connect(self.task_stopped)
        QgsTaskManager.instance().addTask( task )
        return task

m=MyPlugin()
tasks=[]
for i in range(10):
    tasks.append(m.newTask( 'task {}'.format(i), 20))
Contributor

nyalldawson commented Apr 18, 2016

@wonder-sk you're troubleshooting was spot-on. It was related to the task objects getting overwritten in Python.

Here's a screencast of the Task Manager in action:
https://www.youtube.com/watch?v=3KzwNErzkJg&feature=youtu.be

This is using the sample code:

from time import sleep
from PyQt.QtCore import QObject

class TestTask( QgsTask ):

    def __init__(self, desc, time ):
        QgsTask.__init__(self, desc )
        self.time = time

    def run(self):
        wait_time = self.time / 100.0
        for i in range(101):
            sleep(wait_time)
            self.setProgress(i)
            if self.isCancelled():
                 self.stopped()
                 return
        self.completed()

class MyPlugin( QObject ):

    def task_begun(self):
        print '"{}" begun'.format( self.sender().description() )

    def task_complete(self):
        print '"{}" complete'.format( self.sender().description() )

    def task_stopped(self):
        print '"{}" cancelled!'.format( self.sender().description() )

    def progress(self,val):
        #print val
        pass

    def newTask(self, task_name, length):
        task = TestTask(task_name, length ) 
        task.begun.connect(self.task_begun)
        task.taskCompleted.connect(self.task_complete)
        task.progressChanged.connect(self.progress)
        task.taskStopped.connect(self.task_stopped)
        QgsTaskManager.instance().addTask( task )
        return task

m=MyPlugin()
tasks=[]
for i in range(10):
    tasks.append(m.newTask( 'task {}'.format(i), 20))
@m-kuhn

This comment has been minimized.

Show comment
Hide comment
@m-kuhn

m-kuhn Apr 18, 2016

Member

That looks amazing.

What do you think about changing the icon to cancel the task? To me it looks like a "restart" icon rather than a "cancel" icon.

Member

m-kuhn commented Apr 18, 2016

That looks amazing.

What do you think about changing the icon to cancel the task? To me it looks like a "restart" icon rather than a "cancel" icon.

@pcav

This comment has been minimized.

Show comment
Hide comment
@pcav

pcav Apr 18, 2016

Member

Agreed on both points, @m-kuhn

Member

pcav commented Apr 18, 2016

Agreed on both points, @m-kuhn

@nyalldawson

This comment has been minimized.

Show comment
Hide comment
@nyalldawson

nyalldawson Apr 18, 2016

Contributor

@m-kuhn yeah the icons are just placeholders for now... I reused existing ones, but I'll make some proper ones for this.

Contributor

nyalldawson commented Apr 18, 2016

@m-kuhn yeah the icons are just placeholders for now... I reused existing ones, but I'll make some proper ones for this.

@nirvn

This comment has been minimized.

Show comment
Hide comment
@nirvn

nirvn Apr 18, 2016

Contributor

Nice. One obvious task that comes to mind is those large composer exports (atlas et cie). Presumably the task would run in the background, have you planned a way to handle subsequent changes to project while the composer exports?

Contributor

nirvn commented Apr 18, 2016

Nice. One obvious task that comes to mind is those large composer exports (atlas et cie). Presumably the task would run in the background, have you planned a way to handle subsequent changes to project while the composer exports?

@nyalldawson

This comment has been minimized.

Show comment
Hide comment
@nyalldawson

nyalldawson Apr 19, 2016

Contributor

Thanks to @NathanW2 there's a new high-level method for creating tasks from python functions without having to subclass QgsTask:

from time import sleep
import random

# a dumb test function
def run(time):
    wait_time = time / 100.0
    sum = 0
    iterations = 0
    for i in range(101):
        sleep(wait_time)

        #use yield to report progress
        yield i

        sum += random.randint(0,100)
        iterations += 1

        if random.randint(0,100) == 7:
            # use QgsTaskException to report errors
            raise QgsTaskException('bad value!')

    # use QgsTaskResult to return results of calculations or operations
    raise QgsTaskResult([sum,iterations])

task=QgsTask.fromFunction('waste cpu', run, 2 )
QgsTaskManager.instance().addTask(task)


#then can use task.error() or task.result() to see results
print task.error()
print task.result()
Contributor

nyalldawson commented Apr 19, 2016

Thanks to @NathanW2 there's a new high-level method for creating tasks from python functions without having to subclass QgsTask:

from time import sleep
import random

# a dumb test function
def run(time):
    wait_time = time / 100.0
    sum = 0
    iterations = 0
    for i in range(101):
        sleep(wait_time)

        #use yield to report progress
        yield i

        sum += random.randint(0,100)
        iterations += 1

        if random.randint(0,100) == 7:
            # use QgsTaskException to report errors
            raise QgsTaskException('bad value!')

    # use QgsTaskResult to return results of calculations or operations
    raise QgsTaskResult([sum,iterations])

task=QgsTask.fromFunction('waste cpu', run, 2 )
QgsTaskManager.instance().addTask(task)


#then can use task.error() or task.result() to see results
print task.error()
print task.result()
@m-kuhn

This comment has been minimized.

Show comment
Hide comment
@m-kuhn

m-kuhn Apr 19, 2016

Member

Is there still a todo list for this or is it ready for review? I think it's worth merging it soon to get a long testing period before the release.

Member

m-kuhn commented Apr 19, 2016

Is there still a todo list for this or is it ready for review? I think it's worth merging it soon to get a long testing period before the release.

@nyalldawson

This comment has been minimized.

Show comment
Hide comment
@nyalldawson

nyalldawson Apr 19, 2016

Contributor

My TODO list would be:

  • finish the GUI for controlling tasks (eg adding the ability to remove old tasks, clear all, cancel all)
  • support progress-less tasks, for tasks which cannot report their progress
  • add support for tasks which cannot be cancelled
  • add python unit tests
  • pause/resume support
  • move instance to QgsApplication
  • handle removing layers with dependant tasks
  • task 'groups'
  • proper clean up of finished tasks
  • bonus points: investigate reporting task progress via desktop notifications, eg showing progress in Windows task bar
Contributor

nyalldawson commented Apr 19, 2016

My TODO list would be:

  • finish the GUI for controlling tasks (eg adding the ability to remove old tasks, clear all, cancel all)
  • support progress-less tasks, for tasks which cannot report their progress
  • add support for tasks which cannot be cancelled
  • add python unit tests
  • pause/resume support
  • move instance to QgsApplication
  • handle removing layers with dependant tasks
  • task 'groups'
  • proper clean up of finished tasks
  • bonus points: investigate reporting task progress via desktop notifications, eg showing progress in Windows task bar
@m-kuhn

This comment has been minimized.

Show comment
Hide comment
@m-kuhn

m-kuhn Apr 19, 2016

Member

Maybe parts can be split out and added in a second batch (e.g. support for tasks which cannot be cancelled or report their progress)?

Member

m-kuhn commented Apr 19, 2016

Maybe parts can be split out and added in a second batch (e.g. support for tasks which cannot be cancelled or report their progress)?

@nyalldawson

This comment has been minimized.

Show comment
Hide comment
@nyalldawson

nyalldawson Apr 19, 2016

Contributor

I'm hoping to get this stuff done today anyway :P if not, i'll defer it to later

Contributor

nyalldawson commented Apr 19, 2016

I'm hoping to get this stuff done today anyway :P if not, i'll defer it to later

@luipir

This comment has been minimized.

Show comment
Hide comment
@luipir

luipir Apr 19, 2016

Contributor

any reason to do not implement or plan pause and resume?

Contributor

luipir commented Apr 19, 2016

any reason to do not implement or plan pause and resume?

@nyalldawson

This comment has been minimized.

Show comment
Hide comment
@nyalldawson

nyalldawson Apr 19, 2016

Contributor

@luipir good idea. I'll add it to the to-do list.

Contributor

nyalldawson commented Apr 19, 2016

@luipir good idea. I'll add it to the to-do list.

@m-kuhn

This comment has been minimized.

Show comment
Hide comment
@m-kuhn

m-kuhn Apr 19, 2016

Member

It would also be nice to have dependencies.
This way processing models could efficiently be processed in parallel.

Member

m-kuhn commented Apr 19, 2016

It would also be nice to have dependencies.
This way processing models could efficiently be processed in parallel.

@wonder-sk

This comment has been minimized.

Show comment
Hide comment
@wonder-sk

wonder-sk Apr 20, 2016

Member

I very much like the approach from @NathanW2

  • How about using ordinary "return" statement to return something from the task rather than raising an exception?
  • It would be good to be able to throw any kind of Python exception from a task, not just QgsTaskException (and task manager should understand it as a fatal error)
  • Not sure if it is a good practice to use "yield" statement for progress reporting... Like this it is impossible to run it as an ordinary function (the yield statement turns it into a generator)
  • How would we support cancellation here? I would suggest passing another argument to these functions - the argument could be used for both cancellation and progress reporting.

Few more suggestions for C++ API:

  • do we need explicit "can report progress" flag? We could simply consider progress as "unknown" by default (e.g. value -1), and if task will start setting progress value, then we assume it can report progress :)
  • it would be good to merge taskCompleted/taskStopped into one (I found that useful also with MTR) - handling of both cases is quite similar
  • would it make sense to put status handling into task manager rather to keep it in QgsTask? My rationale is that run() implementations should not be able change its state arbitrarily, and it is a responsibility of a task manager to keep track of state of its tasks
  • in run() maybe it would be simpler to just use return value true/false rather emitting a signal?
Member

wonder-sk commented Apr 20, 2016

I very much like the approach from @NathanW2

  • How about using ordinary "return" statement to return something from the task rather than raising an exception?
  • It would be good to be able to throw any kind of Python exception from a task, not just QgsTaskException (and task manager should understand it as a fatal error)
  • Not sure if it is a good practice to use "yield" statement for progress reporting... Like this it is impossible to run it as an ordinary function (the yield statement turns it into a generator)
  • How would we support cancellation here? I would suggest passing another argument to these functions - the argument could be used for both cancellation and progress reporting.

Few more suggestions for C++ API:

  • do we need explicit "can report progress" flag? We could simply consider progress as "unknown" by default (e.g. value -1), and if task will start setting progress value, then we assume it can report progress :)
  • it would be good to merge taskCompleted/taskStopped into one (I found that useful also with MTR) - handling of both cases is quite similar
  • would it make sense to put status handling into task manager rather to keep it in QgsTask? My rationale is that run() implementations should not be able change its state arbitrarily, and it is a responsibility of a task manager to keep track of state of its tasks
  • in run() maybe it would be simpler to just use return value true/false rather emitting a signal?
@wonder-sk

This comment has been minimized.

Show comment
Hide comment
@wonder-sk

wonder-sk Apr 20, 2016

Member

Some GUI ideas from PyCharm - I quite like the approach where user is notified about running tasks in status bar (compared to another dock widget) ...

If there is just one task:

one-task

If there are multiple tasks:

more-tasks

If user clicks status bar to see details:

task-details

Member

wonder-sk commented Apr 20, 2016

Some GUI ideas from PyCharm - I quite like the approach where user is notified about running tasks in status bar (compared to another dock widget) ...

If there is just one task:

one-task

If there are multiple tasks:

more-tasks

If user clicks status bar to see details:

task-details

@NathanW2

This comment has been minimized.

Show comment
Hide comment
@NathanW2

NathanW2 Apr 20, 2016

Member
  • How about using ordinary "return" statement to return something from
    the task rather than raising an exception?

Not supported in Py2 only in Py3

On Wed, Apr 20, 2016 at 2:16 PM, Martin Dobias notifications@github.com
wrote:

Some GUI ideas from PyCharm - I quite like the approach where user is
notified about running tasks in status bar (compared to another dock
widget) ...

If there is just one task:

[image: one-task]
https://cloud.githubusercontent.com/assets/193367/14663233/2296ddca-06f1-11e6-9e79-d63e6d9fa825.png

If there are multiple tasks:

[image: more-tasks]
https://cloud.githubusercontent.com/assets/193367/14663238/372a1cf2-06f1-11e6-814a-9732009a8da0.png

If user clicks status bar to see details:

[image: task-details]
https://cloud.githubusercontent.com/assets/193367/14663245/57472ffc-06f1-11e6-9bcb-b0140e4ce43a.png


You are receiving this because you were mentioned.
Reply to this email directly or view it on GitHub
#3004 (comment)

Member

NathanW2 commented Apr 20, 2016

  • How about using ordinary "return" statement to return something from
    the task rather than raising an exception?

Not supported in Py2 only in Py3

On Wed, Apr 20, 2016 at 2:16 PM, Martin Dobias notifications@github.com
wrote:

Some GUI ideas from PyCharm - I quite like the approach where user is
notified about running tasks in status bar (compared to another dock
widget) ...

If there is just one task:

[image: one-task]
https://cloud.githubusercontent.com/assets/193367/14663233/2296ddca-06f1-11e6-9e79-d63e6d9fa825.png

If there are multiple tasks:

[image: more-tasks]
https://cloud.githubusercontent.com/assets/193367/14663238/372a1cf2-06f1-11e6-814a-9732009a8da0.png

If user clicks status bar to see details:

[image: task-details]
https://cloud.githubusercontent.com/assets/193367/14663245/57472ffc-06f1-11e6-9bcb-b0140e4ce43a.png


You are receiving this because you were mentioned.
Reply to this email directly or view it on GitHub
#3004 (comment)

@NathanW2

This comment has been minimized.

Show comment
Hide comment
@NathanW2

NathanW2 Apr 20, 2016

Member

+1 to showing tasks in the status bar.

On Wed, Apr 20, 2016 at 2:17 PM, Nathan Woodrow madmanwoo@gmail.com wrote:

  • How about using ordinary "return" statement to return something from
    the task rather than raising an exception?

Not supported in Py2 only in Py3

On Wed, Apr 20, 2016 at 2:16 PM, Martin Dobias notifications@github.com
wrote:

Some GUI ideas from PyCharm - I quite like the approach where user is
notified about running tasks in status bar (compared to another dock
widget) ...

If there is just one task:

[image: one-task]
https://cloud.githubusercontent.com/assets/193367/14663233/2296ddca-06f1-11e6-9e79-d63e6d9fa825.png

If there are multiple tasks:

[image: more-tasks]
https://cloud.githubusercontent.com/assets/193367/14663238/372a1cf2-06f1-11e6-814a-9732009a8da0.png

If user clicks status bar to see details:

[image: task-details]
https://cloud.githubusercontent.com/assets/193367/14663245/57472ffc-06f1-11e6-9bcb-b0140e4ce43a.png


You are receiving this because you were mentioned.
Reply to this email directly or view it on GitHub
#3004 (comment)

Member

NathanW2 commented Apr 20, 2016

+1 to showing tasks in the status bar.

On Wed, Apr 20, 2016 at 2:17 PM, Nathan Woodrow madmanwoo@gmail.com wrote:

  • How about using ordinary "return" statement to return something from
    the task rather than raising an exception?

Not supported in Py2 only in Py3

On Wed, Apr 20, 2016 at 2:16 PM, Martin Dobias notifications@github.com
wrote:

Some GUI ideas from PyCharm - I quite like the approach where user is
notified about running tasks in status bar (compared to another dock
widget) ...

If there is just one task:

[image: one-task]
https://cloud.githubusercontent.com/assets/193367/14663233/2296ddca-06f1-11e6-9e79-d63e6d9fa825.png

If there are multiple tasks:

[image: more-tasks]
https://cloud.githubusercontent.com/assets/193367/14663238/372a1cf2-06f1-11e6-814a-9732009a8da0.png

If user clicks status bar to see details:

[image: task-details]
https://cloud.githubusercontent.com/assets/193367/14663245/57472ffc-06f1-11e6-9bcb-b0140e4ce43a.png


You are receiving this because you were mentioned.
Reply to this email directly or view it on GitHub
#3004 (comment)

@NathanW2

This comment has been minimized.

Show comment
Hide comment
@NathanW2

NathanW2 Apr 20, 2016

Member

Following on from Martins comments about using yield. Here is an idea:

Using the task object itself to report the status directly to thatl:

def myfunc(task):
    for i in range(100):
         task.setProgress(i)
         if i > 100:
             raise Exception("Bam!")
         if i > 200:
             task.cancel()
    return True

This changes the task manager to be like this in the call:

class QgsTaskWrapper(QgsTask):

    def __init__(self, description, function, *extraArgs):
        QgsTask.__init__(self, description, QgsTask.ProgressReport)
        self.extraArgs = extraArgs
        self.function = function
        self.task_result = None
        self.task_error = None

    def run(self):
        try:
           self.function(self, *self.extraArgs)
        expect Exception as ex:
             # report error
             return
        self.completed()

This design might be a bit clearer on intent and also lets you do this:

class FakeTask:
     def setProgess(self, status):
          # Don't care 
          pass

myfunc(FakeTask)

If you want to unit test or use out side of the task manager.

The other option is to pass in it into the function as kwarg so it's optional for people:

def myfunc(**kwargs):
      # I don't care about reporting status so you can pass it in
      # and I will just ignore it
      # or kwargs['task'] if I do.
class QgsTaskWrapper(QgsTask):

    def __init__(self, description, function, *extraArgs):
        QgsTask.__init__(self, description, QgsTask.ProgressReport)
        self.extraArgs = extraArgs
        .....

    def run(self):
        try:
           extra = dict(task=self)
           self.function(*self.extraArgs, **extra)
        expect Exception as ex:
             # report error
             return
        self.completed()
Member

NathanW2 commented Apr 20, 2016

Following on from Martins comments about using yield. Here is an idea:

Using the task object itself to report the status directly to thatl:

def myfunc(task):
    for i in range(100):
         task.setProgress(i)
         if i > 100:
             raise Exception("Bam!")
         if i > 200:
             task.cancel()
    return True

This changes the task manager to be like this in the call:

class QgsTaskWrapper(QgsTask):

    def __init__(self, description, function, *extraArgs):
        QgsTask.__init__(self, description, QgsTask.ProgressReport)
        self.extraArgs = extraArgs
        self.function = function
        self.task_result = None
        self.task_error = None

    def run(self):
        try:
           self.function(self, *self.extraArgs)
        expect Exception as ex:
             # report error
             return
        self.completed()

This design might be a bit clearer on intent and also lets you do this:

class FakeTask:
     def setProgess(self, status):
          # Don't care 
          pass

myfunc(FakeTask)

If you want to unit test or use out side of the task manager.

The other option is to pass in it into the function as kwarg so it's optional for people:

def myfunc(**kwargs):
      # I don't care about reporting status so you can pass it in
      # and I will just ignore it
      # or kwargs['task'] if I do.
class QgsTaskWrapper(QgsTask):

    def __init__(self, description, function, *extraArgs):
        QgsTask.__init__(self, description, QgsTask.ProgressReport)
        self.extraArgs = extraArgs
        .....

    def run(self):
        try:
           extra = dict(task=self)
           self.function(*self.extraArgs, **extra)
        expect Exception as ex:
             # report error
             return
        self.completed()
@nyalldawson

This comment has been minimized.

Show comment
Hide comment
@nyalldawson

nyalldawson Apr 20, 2016

Contributor

@NathanW2 that's much nicer! I'll do that approach.

Contributor

nyalldawson commented Apr 20, 2016

@NathanW2 that's much nicer! I'll do that approach.

@nirvn

This comment has been minimized.

Show comment
Hide comment
@nirvn

nirvn Apr 21, 2016

Contributor

@wonder-sk I like the UI shots above. This makes me think the plugin update check-up could be one of the first bit ported to @nyalldawson 's task manager.

Exciting stuff all around.

Contributor

nirvn commented Apr 21, 2016

@wonder-sk I like the UI shots above. This makes me think the plugin update check-up could be one of the first bit ported to @nyalldawson 's task manager.

Exciting stuff all around.

@NathanW2

This comment has been minimized.

Show comment
Hide comment
@NathanW2

NathanW2 Apr 21, 2016

Member
Member

NathanW2 commented Apr 21, 2016

@nyalldawson

This comment has been minimized.

Show comment
Hide comment
@nyalldawson

nyalldawson Apr 21, 2016

Contributor

Ok, added some stuff:

  • task dependencies
  • ability to 'hold' (pause) a task BEFORE it has started to run. I don't think it's worth adding support for pausing running tasks, given the complexity involved for derived tasks to handle this correctly.

Here's the (pretty damn awesome!) test case now:

from time import sleep
import random

# a dumb test function
def run(task,time):
    wait_time = time / 100.0
    sum = 0
    iterations = 0
    for i in range(101):
        sleep(wait_time)

        # use task.setProgress to report progress
        task.setProgress( i )

        sum += random.randint(0,100)
        iterations += 1

        # check task.isCancelled() to handle cancellation
        if task.isCancelled():
            stopped()
            return

        # raise expections to abort task
        #if random.randint(0,100) == 7:
        #    raise Exception('bad value!')

    # use task object to store results of calculations or operations
    task.result = [sum,iterations]

# a bunch of tasks
task=QgsTask.fromFunction('waste cpu', run, 2 )
task2=QgsTask.fromFunction('waste cpu 2', run, 4 )
task3=QgsTask.fromFunction('waste cpu 3', run, 2 )
task4=QgsTask.fromFunction('waste cpu 4', run, 3 )

# add them, with various co-dependencies... and let it do its thing!
QgsTaskManager.instance().addTask(task,[task2,task3])
QgsTaskManager.instance().addTask(task2,[task4])
QgsTaskManager.instance().addTask(task3,[task4])
QgsTaskManager.instance().addTask(task4)
Contributor

nyalldawson commented Apr 21, 2016

Ok, added some stuff:

  • task dependencies
  • ability to 'hold' (pause) a task BEFORE it has started to run. I don't think it's worth adding support for pausing running tasks, given the complexity involved for derived tasks to handle this correctly.

Here's the (pretty damn awesome!) test case now:

from time import sleep
import random

# a dumb test function
def run(task,time):
    wait_time = time / 100.0
    sum = 0
    iterations = 0
    for i in range(101):
        sleep(wait_time)

        # use task.setProgress to report progress
        task.setProgress( i )

        sum += random.randint(0,100)
        iterations += 1

        # check task.isCancelled() to handle cancellation
        if task.isCancelled():
            stopped()
            return

        # raise expections to abort task
        #if random.randint(0,100) == 7:
        #    raise Exception('bad value!')

    # use task object to store results of calculations or operations
    task.result = [sum,iterations]

# a bunch of tasks
task=QgsTask.fromFunction('waste cpu', run, 2 )
task2=QgsTask.fromFunction('waste cpu 2', run, 4 )
task3=QgsTask.fromFunction('waste cpu 3', run, 2 )
task4=QgsTask.fromFunction('waste cpu 4', run, 3 )

# add them, with various co-dependencies... and let it do its thing!
QgsTaskManager.instance().addTask(task,[task2,task3])
QgsTaskManager.instance().addTask(task2,[task4])
QgsTaskManager.instance().addTask(task3,[task4])
QgsTaskManager.instance().addTask(task4)
@luipir

This comment has been minimized.

Show comment
Hide comment
@luipir

luipir Apr 21, 2016

Contributor

About the pause I almost agree

imagine a developer that want to integrate his algorithm composed of different steps!
I can derive QgsTaskWrapper and overload run() adding several function call. In this case pause/resume during running make sense.

Would be possibile that every QgsTask or QgsTaskWrapper expose kind of capability? I agree that puasing a function call not designed to be paused create more problem that advantages.

btw... all thes pause/resume and capability can be moved at Processing Toollbox level.

Contributor

luipir commented Apr 21, 2016

About the pause I almost agree

imagine a developer that want to integrate his algorithm composed of different steps!
I can derive QgsTaskWrapper and overload run() adding several function call. In this case pause/resume during running make sense.

Would be possibile that every QgsTask or QgsTaskWrapper expose kind of capability? I agree that puasing a function call not designed to be paused create more problem that advantages.

btw... all thes pause/resume and capability can be moved at Processing Toollbox level.

@nyalldawson

This comment has been minimized.

Show comment
Hide comment
@nyalldawson

nyalldawson Apr 22, 2016

Contributor

@luipir

imagine a developer that want to integrate his algorithm composed of different steps!
I can derive QgsTaskWrapper and overload run() adding several function call. In this case pause/resume during running make sense.

In this case I'd suggest adding them as separate tasks which are dependent on each other. (The task manager handles dependencies now.) That should make it easier for developers to use this functionality, since all the dependency handling is done by the task manager itself. Tasks are queued until all their dependencies have run, and then they are automatically fired up when the next thread becomes available.

We could potentially add a concept of task "collections" - which are a set of tasks that are only shown to users as a single running task (some other task manager implementations do this). Pausing a task collection would then be done by placing all non-running sub-tasks "on hold". Ie, any active tasks will complete, but follow-on dependent tasks will not fire up until the collection is un-held.

Contributor

nyalldawson commented Apr 22, 2016

@luipir

imagine a developer that want to integrate his algorithm composed of different steps!
I can derive QgsTaskWrapper and overload run() adding several function call. In this case pause/resume during running make sense.

In this case I'd suggest adding them as separate tasks which are dependent on each other. (The task manager handles dependencies now.) That should make it easier for developers to use this functionality, since all the dependency handling is done by the task manager itself. Tasks are queued until all their dependencies have run, and then they are automatically fired up when the next thread becomes available.

We could potentially add a concept of task "collections" - which are a set of tasks that are only shown to users as a single running task (some other task manager implementations do this). Pausing a task collection would then be done by placing all non-running sub-tasks "on hold". Ie, any active tasks will complete, but follow-on dependent tasks will not fire up until the collection is un-held.

@nyalldawson

This comment has been minimized.

Show comment
Hide comment
@nyalldawson

nyalldawson Apr 22, 2016

Contributor

@wonder-sk

do we need explicit "can report progress" flag? We could simply consider progress as "unknown" by default (e.g. value -1), and if task will start setting progress value, then we assume it can report progress :)

I'll keep this in mind... I still need to work out the best way to present non-progress reporting tasks to users.

it would be good to merge taskCompleted/taskStopped into one (I found that useful also with MTR) - handling of both cases is quite similar

I could add a new signal for "finished" which is called in both cases?

would it make sense to put status handling into task manager rather to keep it in QgsTask? My rationale is that run() implementations should not be able change its state arbitrarily, and it is a responsibility of a task manager to keep track of state of its tasks

I disagree. I think it's useful for a task to also be able to report this itself. Eg if a task uses some external signal as an indication for completion/failure (eg a download task).

in run() maybe it would be simpler to just use return value true/false rather emitting a signal?

This approach would only work for procedural tasks. If we leave it with the signal approach then tasks can use other qobject signal/slots to handle their workload.

Contributor

nyalldawson commented Apr 22, 2016

@wonder-sk

do we need explicit "can report progress" flag? We could simply consider progress as "unknown" by default (e.g. value -1), and if task will start setting progress value, then we assume it can report progress :)

I'll keep this in mind... I still need to work out the best way to present non-progress reporting tasks to users.

it would be good to merge taskCompleted/taskStopped into one (I found that useful also with MTR) - handling of both cases is quite similar

I could add a new signal for "finished" which is called in both cases?

would it make sense to put status handling into task manager rather to keep it in QgsTask? My rationale is that run() implementations should not be able change its state arbitrarily, and it is a responsibility of a task manager to keep track of state of its tasks

I disagree. I think it's useful for a task to also be able to report this itself. Eg if a task uses some external signal as an indication for completion/failure (eg a download task).

in run() maybe it would be simpler to just use return value true/false rather emitting a signal?

This approach would only work for procedural tasks. If we leave it with the signal approach then tasks can use other qobject signal/slots to handle their workload.

@nyalldawson

This comment has been minimized.

Show comment
Hide comment
@nirvn

This comment has been minimized.

Show comment
Hide comment
@nirvn

nirvn Apr 28, 2016

Contributor

@nyalldawson very nice. IMO, you'll need some sort of a symbol / button next to the status bar's progress bar to indicate to users that the widget is interactive (i.e. that you can click on it, etc.).

Contributor

nirvn commented Apr 28, 2016

@nyalldawson very nice. IMO, you'll need some sort of a symbol / button next to the status bar's progress bar to indicate to users that the widget is interactive (i.e. that you can click on it, etc.).

@wonder-sk

This comment has been minimized.

Show comment
Hide comment
@wonder-sk

wonder-sk Apr 29, 2016

Member

@nyalldawson the video looks great - definitely towards the right direction :-)

One thing I am also looking forward to are "temporary" tasks (how to best call them?) - the tasks that are also automatically cleaned out of the queue once they finish.

Member

wonder-sk commented Apr 29, 2016

@nyalldawson the video looks great - definitely towards the right direction :-)

One thing I am also looking forward to are "temporary" tasks (how to best call them?) - the tasks that are also automatically cleaned out of the queue once they finish.

@wonder-sk

This comment has been minimized.

Show comment
Hide comment
@wonder-sk

wonder-sk Apr 29, 2016

Member

it would be good to merge taskCompleted/taskStopped into one (I found that useful also with MTR) - handling of both cases is quite similar

I could add a new signal for "finished" which is called in both cases?

Actually just now I had a thought we could have "finished" virtual method in QgsTask that would be called upon completion/cancellation. Most of the time one needs to do something afterwards, so it may be more practical to have an ordinary function for it instead of using signals+slots. Also, it may help to better define task's lifecycle - after finished() method is done, the task should be good to be deleted by task manager (so the caller does not need to worry about cleanup). The finished() method would be guaranteed to be called from the main thread.

would it make sense to put status handling into task manager rather to keep it in QgsTask? My rationale is that run() implementations should not be able change its state arbitrarily, and it is a responsibility of a task manager to keep track of state of its tasks

I disagree. I think it's useful for a task to also be able to report this itself. Eg if a task uses some external signal as an indication for completion/failure (eg a download task).

With the finished() method as suggested above, there would be actually no need for any signals directly from the task - it may simplify the design a bit. Signals/slots are great, but it is best to avoid them unless strictly necessary, especially in multi-threaded code.

in run() maybe it would be simpler to just use return value true/false rather emitting a signal?

This approach would only work for procedural tasks. If we leave it with the signal approach then tasks can use other qobject signal/slots to handle their workload.

I thought that if tasks need to return something out of the task itself, they would store the result(s) in member variables... Returning values in signals/slots does not feel right to me, and it also has the disadvantage that objects need to be registered in Qt metatype system (at least in c++) .

Member

wonder-sk commented Apr 29, 2016

it would be good to merge taskCompleted/taskStopped into one (I found that useful also with MTR) - handling of both cases is quite similar

I could add a new signal for "finished" which is called in both cases?

Actually just now I had a thought we could have "finished" virtual method in QgsTask that would be called upon completion/cancellation. Most of the time one needs to do something afterwards, so it may be more practical to have an ordinary function for it instead of using signals+slots. Also, it may help to better define task's lifecycle - after finished() method is done, the task should be good to be deleted by task manager (so the caller does not need to worry about cleanup). The finished() method would be guaranteed to be called from the main thread.

would it make sense to put status handling into task manager rather to keep it in QgsTask? My rationale is that run() implementations should not be able change its state arbitrarily, and it is a responsibility of a task manager to keep track of state of its tasks

I disagree. I think it's useful for a task to also be able to report this itself. Eg if a task uses some external signal as an indication for completion/failure (eg a download task).

With the finished() method as suggested above, there would be actually no need for any signals directly from the task - it may simplify the design a bit. Signals/slots are great, but it is best to avoid them unless strictly necessary, especially in multi-threaded code.

in run() maybe it would be simpler to just use return value true/false rather emitting a signal?

This approach would only work for procedural tasks. If we leave it with the signal approach then tasks can use other qobject signal/slots to handle their workload.

I thought that if tasks need to return something out of the task itself, they would store the result(s) in member variables... Returning values in signals/slots does not feel right to me, and it also has the disadvantage that objects need to be registered in Qt metatype system (at least in c++) .

@rduivenvoorde

This comment has been minimized.

Show comment
Hide comment
@rduivenvoorde

rduivenvoorde May 24, 2016

Contributor

(I think I said it before, but) this looks really nice!

One use-case detail I'm wondering about: can I 'open/close' the taskmanager widget (with the progressbars) programmatically. Probably yes: normal widget... click on it? In this way I can make it VERY clear that the task is started and progress can be viewed.

But also wondering if maybe adding a 'open taskmanager' option in api could be an option?

Even more brainwaving, can I stack tasks? So when one is finished, another can be started .... now thinking about processing modelling.... how is this related to each other?

Contributor

rduivenvoorde commented May 24, 2016

(I think I said it before, but) this looks really nice!

One use-case detail I'm wondering about: can I 'open/close' the taskmanager widget (with the progressbars) programmatically. Probably yes: normal widget... click on it? In this way I can make it VERY clear that the task is started and progress can be viewed.

But also wondering if maybe adding a 'open taskmanager' option in api could be an option?

Even more brainwaving, can I stack tasks? So when one is finished, another can be started .... now thinking about processing modelling.... how is this related to each other?

@nyalldawson

This comment has been minimized.

Show comment
Hide comment
@nyalldawson

nyalldawson May 24, 2016

Contributor

@rduivenvoorde thanks for the feedback!

One use-case detail I'm wondering about: can I 'open/close' the taskmanager widget (with the progressbars) programmatically. Probably yes: normal widget... click on it? In this way I can make it VERY clear that the task is started and progress can be viewed.
But also wondering if maybe adding a 'open taskmanager' option in api could be an option?

I'll keep this in mind - should be easy to do.

Even more brainwaving, can I stack tasks? So when one is finished, another can be started .... now thinking about processing modelling.... how is this related to each other?

That's in there already - you can specify dependent tasks, so that the first task (or tasks) have to finish before the next is fired up. The manager will automatically handle starting them in order and when threads become available. It's quite magical to see in operation!

Ideally processing will switch to using this framework, so I'm hoping to get confirmation that the design is looking good for their use case too!

Contributor

nyalldawson commented May 24, 2016

@rduivenvoorde thanks for the feedback!

One use-case detail I'm wondering about: can I 'open/close' the taskmanager widget (with the progressbars) programmatically. Probably yes: normal widget... click on it? In this way I can make it VERY clear that the task is started and progress can be viewed.
But also wondering if maybe adding a 'open taskmanager' option in api could be an option?

I'll keep this in mind - should be easy to do.

Even more brainwaving, can I stack tasks? So when one is finished, another can be started .... now thinking about processing modelling.... how is this related to each other?

That's in there already - you can specify dependent tasks, so that the first task (or tasks) have to finish before the next is fired up. The manager will automatically handle starting them in order and when threads become available. It's quite magical to see in operation!

Ideally processing will switch to using this framework, so I'm hoping to get confirmation that the design is looking good for their use case too!

@alexbruy

This comment has been minimized.

Show comment
Hide comment
@alexbruy

alexbruy May 24, 2016

Contributor

Ideally processing will switch to using this framework, so I'm hoping to get confirmation that the design is looking good for their use case too!

In the beginning of the year I've implemented multithreading support in Processing using QRunnable and QThreadPool. If this will be merged I think we can throw away our implementation and use task manager instead.

Contributor

alexbruy commented May 24, 2016

Ideally processing will switch to using this framework, so I'm hoping to get confirmation that the design is looking good for their use case too!

In the beginning of the year I've implemented multithreading support in Processing using QRunnable and QThreadPool. If this will be merged I think we can throw away our implementation and use task manager instead.

@mbernasocchi

This comment has been minimized.

Show comment
Hide comment
@mbernasocchi

mbernasocchi May 28, 2016

Member

I had implemented a class (/set of classes) dealing with the same problem for plugins, and ended up using a similar approach. having this directly in core would be great.

The implementation looks really good to me. The only point I'm not super convinced is the naming of QgsTask.fromFunction. but also I've no better idea :/

Member

mbernasocchi commented May 28, 2016

I had implemented a class (/set of classes) dealing with the same problem for plugins, and ended up using a similar approach. having this directly in core would be great.

The implementation looks really good to me. The only point I'm not super convinced is the naming of QgsTask.fromFunction. but also I've no better idea :/

nyalldawson added some commits Nov 2, 2016

Simplify reporting completion of tasks
QgsTask subclasses can now return a result (success or fail)
directly from QgsTask::run. If they do so then there's no
need for them to manually call completed() or stopped()
to report their completion.

Alternatively, tasks can also return the ResultPending value
to indicate that the task is still operating and will
manually report its completion by calling completed() or
stopped(). This may be useful for tasks which rely on external
events for completion, eg downloading a file. In this case
Qt slots could be created which are connected to the download
completion or termination and which call completed() or
stopped() to indicate the task has finished operations.
Allow QgsTask subclasses to defined a finished function, which is
called when the task has completed (successfully or otherwise).

This allows for simpler task design when the signal/slot
based approach is not required. Just implement run() with your
heavy lifting, and finished() to do whatever follow up stuff
should happen after the task is complete. finished is always
called from the main thread, so it's safe to do GUI operations
here.

Python based tasks using the simplified QgsTask.fromFunction
approach can now set a on_finished argument to a function
to call when the task is complete.

eg:

def calculate(task):
    # pretend this is some complex maths and stuff we want
    # to run in the background
    return 5*6

def calculation_finished(result, value=None):
    if result == QgsTask.ResultSuccess:
	iface.messageBar().pushMessage(
            'the magic number is {}'.format(value))
    elif result == QgsTask.ResultFail:
        iface.messageBar().pushMessage(
            'couldn\'t work it out, sorry')

task = QgsTask.fromFunction('my task', calculate,
		on_finished=calculation_finished)
QgsTaskManager.instance().addTask(task)

Multiple values can also be returned, eg:

def calculate(task):
    return (4, 8, 15)

def calculation_finished(result, count=None, max=None, sum=None):
    # here:
    # count = 4
    # max = 8
    # sum = 15

task = QgsTask.fromFunction('my task', calculate,
		on_finished=calculation_finished)
QgsTaskManager.instance().addTask(task)
Remove delete* methods from QgsTaskManager API
On further consideration allowing external control of task
deletion is a bad idea.
Remove QgsTaskManager singleton, and instead attach an instance
to QgsApplication.

ie instead of QgsTaskManager.instance(), use
QgsApplication.taskManager()
QgsTasks can have subtasks
Now, a QgsTask can have subtask QgsTasks set by calling
QgsTask::addSubTask. Sub tasks can have their own set of
dependent tasks.

Subtasks are not visible to users, and users only see the overall
progress and status of the parent task.

This allows creation of tasks which are themselves built off
many smaller component tasks. The task manager will still handle
firing up and scheduling the subtasks, so eg subtasks can run
in parallel (if their dependancies allow this).

Subtasks can themselves have subtasks.

This change is designed to allow the processing concept of
algorithms and modeller algorithms to be translatable
directly to the task manager architecture.
Harden everything up
And finally fix a racy condition which has been plaguing me for
ages
Fix more racy conditions
Switch from QtConcurrent::run to QThreadPool::start

QtConcurrent doesn't give us anyway to cancel queued but not
started tasks. QThreadPool does (and also allows us to specify
task priority)
QThreadPool::cancel was introduced in Qt5.5, so no cancellation
possible for Qt < 5.5

Moral of the story: if you run outdated libraries, you can't
expect full functionality from your apps ;)
QgsTask is no longer a QRunnable
Instead use a private wrapper class to make QgsTask compatible
with QThreadPool
Make QgsTask::start private
Only allow starting tasks to be done by QgsTaskManager
Force QgsTask::run() to fully complete
Remove support for signal based completion/termination

Also unfortunately disable a lot of the test suite as a result,
since it's not easily translatable
@nyalldawson

This comment has been minimized.

Show comment
Hide comment
@nyalldawson

nyalldawson Dec 5, 2016

Contributor

@wonder-sk Ok, I've implemented everything you suggested, with the exceptions from my original response.

(ie, I consider this complete now ;) )

Contributor

nyalldawson commented Dec 5, 2016

@wonder-sk Ok, I've implemented everything you suggested, with the exceptions from my original response.

(ie, I consider this complete now ;) )

@wonder-sk

This comment has been minimized.

Show comment
Hide comment
@wonder-sk

wonder-sk Dec 5, 2016

Member

@nyalldawson Awesome! Thanks a lot for that!

For sub-tasks, your recent updates make my previous concerns invalid (for progress hijacking I must have misread the code, sorry).

For copy_func(), I would suggest we either remove it or add a note why it is necessary...

+100 from me, please merge! 🎉

Member

wonder-sk commented Dec 5, 2016

@nyalldawson Awesome! Thanks a lot for that!

For sub-tasks, your recent updates make my previous concerns invalid (for progress hijacking I must have misread the code, sorry).

For copy_func(), I would suggest we either remove it or add a note why it is necessary...

+100 from me, please merge! 🎉

@nyalldawson

This comment has been minimized.

Show comment
Hide comment
@nyalldawson

nyalldawson Dec 5, 2016

Contributor

Thanks @wonder-sk

For copy_func(), I would suggest we either remove it or add a note why it is necessary...

I'll kill it.. like I said, it was an attempt to solve an unrelated crash which I'd just left in for safety. Easy enough to re-introduce if it's solving a real problem in future.

Contributor

nyalldawson commented Dec 5, 2016

Thanks @wonder-sk

For copy_func(), I would suggest we either remove it or add a note why it is necessary...

I'll kill it.. like I said, it was an attempt to solve an unrelated crash which I'd just left in for safety. Easy enough to re-introduce if it's solving a real problem in future.

@nyalldawson nyalldawson closed this Dec 5, 2016

@nyalldawson nyalldawson reopened this Dec 5, 2016

@nyalldawson nyalldawson merged commit b1b6473 into qgis:master Dec 5, 2016

1 check passed

continuous-integration/travis-ci/pr The Travis CI build passed
Details

@nyalldawson nyalldawson deleted the nyalldawson:task_manager branch Dec 5, 2016

@nyalldawson

This comment has been minimized.

Show comment
Hide comment
@nyalldawson

nyalldawson Dec 5, 2016

Contributor

Merged! Thanks to everyone for all the detailed feedback and suggestions, and for the QGIS org for the grant to finish this off! :D

Contributor

nyalldawson commented Dec 5, 2016

Merged! Thanks to everyone for all the detailed feedback and suggestions, and for the QGIS org for the grant to finish this off! :D

@NathanW2

This comment has been minimized.

Show comment
Hide comment
Member

NathanW2 commented Dec 5, 2016

nuyttbnh

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment