1# Copyright 2013 The Distro Tracker Developers
2# See the COPYRIGHT file at the top-level directory of this distribution and
3# at https://deb.li/DTAuthors
4#
5# This file is part of Distro Tracker. It is subject to the license terms
6# in the LICENSE file found in the top-level directory of this
7# distribution and at https://deb.li/DTLicense. No part of Distro Tracker,
8# including this file, may be copied, modified, propagated, or distributed
9# except according to the terms contained in the LICENSE file.
11"""
12Defines and implements all Distro Tracker control commands.
13"""
15import inspect
16import sys
18from django.conf import settings
20from distro_tracker.core.utils import distro_tracker_render_to_string
21from distro_tracker.mail.control.commands.base import Command
22from distro_tracker.mail.control.commands.keywords import ( # noqa
23 ViewDefaultKeywordsCommand,
24 ViewPackageKeywordsCommand,
25 SetDefaultKeywordsCommand,
26 SetPackageKeywordsCommand,
27)
28from distro_tracker.mail.control.commands.teams import ( # noqa
29 JoinTeam,
30 LeaveTeam,
31 ListTeamPackages,
32 WhichTeams,
33)
34from distro_tracker.mail.control.commands.misc import ( # noqa
35 SubscribeCommand,
36 UnsubscribeCommand,
37 WhichCommand,
38 WhoCommand,
39 QuitCommand,
40 UnsubscribeallCommand,
41)
42from distro_tracker.mail.control.commands.confirmation import ( # noqa
43 ConfirmCommand
44)
46MAX_ALLOWED_ERRORS = settings.DISTRO_TRACKER_MAX_ALLOWED_ERRORS_CONTROL_COMMANDS
49class HelpCommand(Command):
50 """
51 Displays help for all the other commands -- their description.
52 """
53 META = {
54 'description': '''help
55 Shows all available commands''',
56 'name': 'help',
57 'position': 5,
58 }
60 REGEX_LIST = (
61 r'$',
62 )
64 def handle(self):
65 self.reply(distro_tracker_render_to_string('control/help.txt', {
66 'descriptions': [
67 command.META.get('description', '')
68 for command in UNIQUE_COMMANDS
69 ],
70 }))
73UNIQUE_COMMANDS = sorted(
74 (klass
75 for _, klass in inspect.getmembers(sys.modules[__name__], inspect.isclass)
76 if klass != Command and issubclass(klass, Command)),
77 key=lambda cmd: cmd.META.get('position', float('inf'))
78)
79"""
80A list of all :py:class:`Command` that are defined.
81"""
84class CommandFactory(object):
85 """
86 Creates instances of
87 :py:class:`Command <distro_tracker.mail.control.commands.base.Command>`
88 classes based on the given context.
90 Context is used to fill in parameters when the command has not found
91 it in the given command line.
92 """
93 def __init__(self, context):
94 #: A dict which is used to fill in parameters' values when they are not
95 #: found in the command line.
96 self.context = context
98 def get_command_function(self, line):
99 """
100 Returns a function which executes the functionality of the command
101 which corresponds to the given arguments.
103 :param line: The line for which a command function should be returned.
104 :type line: string
106 :returns: A callable which when called executes the functionality of a
107 command matching the given line.
108 :rtype: :py:class:`Command
109 <distro_tracker.mail.control.commands.base.Command>` subclass
110 """
111 for cmd in UNIQUE_COMMANDS:
112 # Command exists
113 match = cmd.match_line(line)
114 if not match:
115 continue
116 kwargs = match.groupdict()
117 if not kwargs:
118 # No named patterns found, pass them in the order they were
119 # matched.
120 args = match.groups()
121 return cmd(*args)
122 else:
123 # Update the arguments which weren't matched from the given
124 # context, if available.
125 kwargs.update({
126 key: value
127 for key, value in self.context.items()
128 if key in kwargs and not kwargs[key] and value
129 })
130 command = cmd(**kwargs)
131 command.context = dict(self.context.items())
132 return command
135class CommandProcessor(object):
136 """
137 A class which performs command processing.
138 """
139 def __init__(self, factory, confirmed=False):
140 """
141 :param factory: Used to obtain
142 :py:class:`Command
143 <distro_tracker.mail.control.commands.base.Command>` instances
144 from command text which is processed.
145 :type factory: :py:class`CommandFactory` instance
146 :param confirmed: Indicates whether the commands being executed have
147 already been confirmed or if those which require confirmation will
148 be added to the set of commands requiring confirmation.
149 :type confirmed: Boolean
150 """
151 self.factory = factory
152 self.confirmed = confirmed
153 self.confirmation_set = None
155 self.out = []
156 self.errors = 0
157 self.processed = set()
159 def echo_command(self, line):
160 """
161 Echoes the line to the command processing output. The line is quoted in
162 the output.
164 :param line: The line to be echoed back to the output.
165 """
166 self.out.append('> ' + line)
168 def output(self, text):
169 """
170 Include the given line in the command processing output.
172 :param line: The line of text to be included in the output.
173 """
174 self.out.append(text)
176 def run_command(self, command):
177 """
178 Runs the given command.
180 :param command: The command to be ran.
181 :type command: :py:class:`Command
182 <distro_tracker.mail.control.commands.base.Command>`
183 """
184 if command.get_command_text() not in self.processed:
185 # Only process the command if it was not previously processed.
186 if getattr(command, 'needs_confirmation', False):
187 command.is_confirmed = self.confirmed
188 command.confirmation_set = self.confirmation_set
189 # Now run the command
190 command_output = command()
191 if not command_output: 191 ↛ 192line 191 didn't jump to line 192, because the condition on line 191 was never true
192 command_output = ''
193 self.output(command_output)
194 self.processed.add(command.get_command_text())
196 def process(self, lines):
197 """
198 Processes all the given lines of text which are interpreted as
199 commands.
201 :param lines: A list of strings each representing a single line which
202 is to be regarded as a command.
203 """
204 if self.errors == MAX_ALLOWED_ERRORS: 204 ↛ 205line 204 didn't jump to line 205, because the condition on line 204 was never true
205 return
207 for line in lines:
208 line = line.strip()
209 self.echo_command(line)
211 if not line or line.startswith('#'):
212 continue
213 command = self.factory.get_command_function(line)
215 if not command:
216 self.errors += 1
217 if self.errors == MAX_ALLOWED_ERRORS:
218 self.output(
219 '{MAX_ALLOWED_ERRORS} lines '
220 'without commands: stopping.'.format(
221 MAX_ALLOWED_ERRORS=MAX_ALLOWED_ERRORS))
222 return
223 else:
224 self.run_command(command)
226 if isinstance(command, QuitCommand):
227 return
229 def is_success(self):
230 """
231 Checks whether any command was successfully processed.
233 :returns True: when at least one command is successfully executed
234 :returns False: when no commands were successfully executed
235 :rtype: Boolean
236 """
237 # Send a response only if there were some commands processed
238 if self.processed:
239 return True
240 else:
241 return False
243 def get_output(self):
244 """
245 Returns the resulting output of processing all given commands.
247 :rtype: string
248 """
249 return '\n'.join(self.out)