From eababcadb0abc2666b57a44f39010165ee0a5898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Fernando=20S=C3=A1nchez?= Date: Fri, 7 Apr 2017 16:13:57 +0200 Subject: [PATCH 1/7] Analysis as strings or objects in results Closes #25 --- .../results/example-analysis-as-id-FAIL.json | 78 +++++++++++++++++++ .../results/example-analysis-as-id.json | 74 ++++++++++++++++++ senpy/schemas/results.json | 10 ++- 3 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 docs/bad-examples/results/example-analysis-as-id-FAIL.json create mode 100644 docs/examples/results/example-analysis-as-id.json diff --git a/docs/bad-examples/results/example-analysis-as-id-FAIL.json b/docs/bad-examples/results/example-analysis-as-id-FAIL.json new file mode 100644 index 0000000..a24e565 --- /dev/null +++ b/docs/bad-examples/results/example-analysis-as-id-FAIL.json @@ -0,0 +1,78 @@ +{ + "@context": "http://mixedemotions-project.eu/ns/context.jsonld", + "@id": "me:Result1", + "@type": "results", + "analysis": [ + "me:SAnalysis1", + "me:SgAnalysis1", + "me:EmotionAnalysis1", + "me:NER1", + { + "@type": "analysis", + "@id": "wrong" + } + ], + "entries": [ + { + "@id": "http://micro.blog/status1", + "@type": [ + "nif:RFC5147String", + "nif:Context" + ], + "nif:isString": "Dear Microsoft, put your Windows Phone on your newest #open technology program. You'll be awesome. #opensource", + "entities": [ + { + "@id": "http://micro.blog/status1#char=5,13", + "nif:beginIndex": 5, + "nif:endIndex": 13, + "nif:anchorOf": "Microsoft", + "me:references": "http://dbpedia.org/page/Microsoft", + "prov:wasGeneratedBy": "me:NER1" + }, + { + "@id": "http://micro.blog/status1#char=25,37", + "nif:beginIndex": 25, + "nif:endIndex": 37, + "nif:anchorOf": "Windows Phone", + "me:references": "http://dbpedia.org/page/Windows_Phone", + "prov:wasGeneratedBy": "me:NER1" + } + ], + "suggestions": [ + { + "@id": "http://micro.blog/status1#char=16,77", + "nif:beginIndex": 16, + "nif:endIndex": 77, + "nif:anchorOf": "put your Windows Phone on your newest #open technology program", + "prov:wasGeneratedBy": "me:SgAnalysis1" + } + ], + "sentiments": [ + { + "@id": "http://micro.blog/status1#char=80,97", + "nif:beginIndex": 80, + "nif:endIndex": 97, + "nif:anchorOf": "You'll be awesome.", + "marl:hasPolarity": "marl:Positive", + "marl:polarityValue": 0.9, + "prov:wasGeneratedBy": "me:SAnalysis1" + } + ], + "emotions": [ + { + "@id": "http://micro.blog/status1#char=0,109", + "nif:anchorOf": "Dear Microsoft, put your Windows Phone on your newest #open technology program. You'll be awesome. #opensource", + "prov:wasGeneratedBy": "me:EAnalysis1", + "onyx:hasEmotion": [ + { + "onyx:hasEmotionCategory": "wna:liking" + }, + { + "onyx:hasEmotionCategory": "wna:excitement" + } + ] + } + ] + } + ] +} diff --git a/docs/examples/results/example-analysis-as-id.json b/docs/examples/results/example-analysis-as-id.json new file mode 100644 index 0000000..95ee7de --- /dev/null +++ b/docs/examples/results/example-analysis-as-id.json @@ -0,0 +1,74 @@ +{ + "@context": "http://mixedemotions-project.eu/ns/context.jsonld", + "@id": "me:Result1", + "@type": "results", + "analysis": [ + "me:SAnalysis1", + "me:SgAnalysis1", + "me:EmotionAnalysis1", + "me:NER1" + ], + "entries": [ + { + "@id": "http://micro.blog/status1", + "@type": [ + "nif:RFC5147String", + "nif:Context" + ], + "nif:isString": "Dear Microsoft, put your Windows Phone on your newest #open technology program. You'll be awesome. #opensource", + "entities": [ + { + "@id": "http://micro.blog/status1#char=5,13", + "nif:beginIndex": 5, + "nif:endIndex": 13, + "nif:anchorOf": "Microsoft", + "me:references": "http://dbpedia.org/page/Microsoft", + "prov:wasGeneratedBy": "me:NER1" + }, + { + "@id": "http://micro.blog/status1#char=25,37", + "nif:beginIndex": 25, + "nif:endIndex": 37, + "nif:anchorOf": "Windows Phone", + "me:references": "http://dbpedia.org/page/Windows_Phone", + "prov:wasGeneratedBy": "me:NER1" + } + ], + "suggestions": [ + { + "@id": "http://micro.blog/status1#char=16,77", + "nif:beginIndex": 16, + "nif:endIndex": 77, + "nif:anchorOf": "put your Windows Phone on your newest #open technology program", + "prov:wasGeneratedBy": "me:SgAnalysis1" + } + ], + "sentiments": [ + { + "@id": "http://micro.blog/status1#char=80,97", + "nif:beginIndex": 80, + "nif:endIndex": 97, + "nif:anchorOf": "You'll be awesome.", + "marl:hasPolarity": "marl:Positive", + "marl:polarityValue": 0.9, + "prov:wasGeneratedBy": "me:SAnalysis1" + } + ], + "emotions": [ + { + "@id": "http://micro.blog/status1#char=0,109", + "nif:anchorOf": "Dear Microsoft, put your Windows Phone on your newest #open technology program. You'll be awesome. #opensource", + "prov:wasGeneratedBy": "me:EAnalysis1", + "onyx:hasEmotion": [ + { + "onyx:hasEmotionCategory": "wna:liking" + }, + { + "onyx:hasEmotionCategory": "wna:excitement" + } + ] + } + ] + } + ] +} diff --git a/senpy/schemas/results.json b/senpy/schemas/results.json index d699b56..06ad88e 100644 --- a/senpy/schemas/results.json +++ b/senpy/schemas/results.json @@ -18,10 +18,16 @@ "type": "string" }, "analysis": { - "type": "array", "default": [], + "type": "array", "items": { - "$ref": "analysis.json" + "anyOf": [ + { + "$ref": "analysis.json" + },{ + "type": "string" + } + ] } }, "entries": { From d3d05b32188ef8e5150406f2a7a4305dca273281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Fernando=20S=C3=A1nchez?= Date: Fri, 7 Apr 2017 16:24:28 +0200 Subject: [PATCH 2/7] Fixed expansion of "plugins" Closes #26 There was no need to add @list, and it was causing JSON-LD to expand the URI of 'plugins' --- senpy/schemas/context.jsonld | 3 --- senpy/schemas/plugins.json | 2 -- tests/test_models.py | 19 ++++++++++++++----- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/senpy/schemas/context.jsonld b/senpy/schemas/context.jsonld index 4b77adb..8ab7bfa 100644 --- a/senpy/schemas/context.jsonld +++ b/senpy/schemas/context.jsonld @@ -37,9 +37,6 @@ "@type": "@id", "@container": "@set" }, - "plugins": { - "@container": "@list" - }, "options": { "@container": "@set" }, diff --git a/senpy/schemas/plugins.json b/senpy/schemas/plugins.json index cd7c937..7cafd9b 100644 --- a/senpy/schemas/plugins.json +++ b/senpy/schemas/plugins.json @@ -10,8 +10,6 @@ "items": { "$ref": "plugin.json" } - }, - "@type": { } } } diff --git a/tests/test_models.py b/tests/test_models.py index 7e3f020..745a439 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -109,13 +109,15 @@ class ModelsTest(TestCase): } }}) c = p.jsonld() - assert "info" not in c - assert "repo" not in c - assert "extra_params" in c - logging.debug("Framed:") + assert '@type' in c + assert c['@type'] == 'plugin' + assert 'info' not in c + assert 'repo' not in c + assert 'extra_params' in c + logging.debug('Framed:') logging.debug(c) p.validate() - assert "es" in c['extra_params']['none']['options'] + assert 'es' in c['extra_params']['none']['options'] assert isinstance(c['extra_params']['none']['options'], list) def test_str(self): @@ -158,6 +160,13 @@ class ModelsTest(TestCase): g = rdflib.Graph().parse(data=t, format='turtle') assert len(g) == len(triples) + def test_plugin_list(self): + """The plugin list should be of type \"plugins\"""" + plugs = Plugins() + c = plugs.jsonld() + assert '@type' in c + assert c['@type'] == 'plugins' + def test_single_plugin(self): """A response with a single plugin should still return a list""" plugs = Plugins() From 14c86ec38c4b73f91a53f5439cf8f0a7ac17fa45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Fernando=20S=C3=A1nchez?= Date: Mon, 10 Apr 2017 17:24:39 +0200 Subject: [PATCH 3/7] Set plugin list as a @set and fixed test case It turns out setting "plugins" as a @list in the context causes the "plugins" property to expand to its full name. Removing the type causes a regression of #17, which I initially missed because the test in #17 was wrong. Closes #26 --- senpy/schemas/context.jsonld | 3 +++ tests/test_models.py | 9 ++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/senpy/schemas/context.jsonld b/senpy/schemas/context.jsonld index 8ab7bfa..86d6c92 100644 --- a/senpy/schemas/context.jsonld +++ b/senpy/schemas/context.jsonld @@ -40,6 +40,9 @@ "options": { "@container": "@set" }, + "plugins": { + "@container": "@set" + }, "prov:wasGeneratedBy": { "@type": "@id" }, diff --git a/tests/test_models.py b/tests/test_models.py index 745a439..f667859 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -170,11 +170,10 @@ class ModelsTest(TestCase): def test_single_plugin(self): """A response with a single plugin should still return a list""" plugs = Plugins() - for i in range(10): - p = Plugin({'id': str(i), - 'version': 0, - 'description': 'dummy'}) - plugs.plugins.append(p) + p = Plugin({'id': str(0), + 'version': 0, + 'description': 'dummy'}) + plugs.plugins.append(p) assert isinstance(plugs.plugins, list) js = plugs.jsonld() assert isinstance(js['plugins'], list) From e0b4c76238a68d0185ae4b17474bc09be3dfe619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Fernando=20S=C3=A1nchez?= Date: Fri, 7 Apr 2017 18:20:38 +0200 Subject: [PATCH 4/7] Add plugin method to client Closes #28 --- senpy/client.py | 5 +++++ senpy/extensions.py | 30 ++---------------------------- senpy/plugins/__init__.py | 38 ++++++++++++++++++++++++++++++++++++++ tests/test_client.py | 25 ++++++++++++++++++++++--- tests/test_models.py | 2 +- 5 files changed, 68 insertions(+), 32 deletions(-) diff --git a/senpy/client.py b/senpy/client.py index e2810f2..5867715 100644 --- a/senpy/client.py +++ b/senpy/client.py @@ -1,6 +1,7 @@ import requests import logging from . import models +from .plugins import default_plugin_type logger = logging.getLogger(__name__) @@ -12,6 +13,10 @@ class Client(object): def analyse(self, input, method='GET', **kwargs): return self.request('/', method=method, input=input, **kwargs) + def plugins(self, ptype=default_plugin_type): + resp = self.request(path='/plugins', plugin_type=ptype).plugins + return {p.name: p for p in resp} + def request(self, path=None, method='GET', **params): url = '{}{}'.format(self.endpoint, path) response = requests.request(method=method, url=url, params=params) diff --git a/senpy/extensions.py b/senpy/extensions.py index 36e2654..83efb86 100644 --- a/senpy/extensions.py +++ b/senpy/extensions.py @@ -183,7 +183,7 @@ class Senpy(object): return resp def _conversion_candidates(self, fromModel, toModel): - candidates = self.filter_plugins(**{'@type': 'emotionConversionPlugin'}) + candidates = self.filter_plugins(plugin_type='emotionConversionPlugin') for name, candidate in candidates.items(): for pair in candidate.onyx__doesConversion: logging.debug(pair) @@ -417,33 +417,7 @@ class Senpy(object): return self._plugin_list def filter_plugins(self, **kwargs): - """ Filter plugins by different criteria """ - ptype = kwargs.pop('plugin_type', None) - logger.debug('#' * 100) - logger.debug('ptype {}'.format(ptype)) - if ptype: - try: - ptype = ptype[0].upper() + ptype[1:] - pclass = getattr(plugins, ptype) - logger.debug('Class: {}'.format(pclass)) - candidates = filter(lambda x: isinstance(x, pclass), - self.plugins.values()) - except AttributeError: - raise Error('{} is not a valid type'.format(ptype)) - else: - candidates = self.plugins.values() - - logger.debug(candidates) - - def matches(plug): - res = all(getattr(plug, k, None) == v for (k, v) in kwargs.items()) - logger.debug( - "matching {} with {}: {}".format(plug.name, kwargs, res)) - return res - - if kwargs: - candidates = filter(matches, candidates) - return {p.name: p for p in candidates} + return plugins.pfilter(self.plugins, **kwargs) @property def analysis_plugins(self): diff --git a/senpy/plugins/__init__.py b/senpy/plugins/__init__.py index bad49ee..b45a7f4 100644 --- a/senpy/plugins/__init__.py +++ b/senpy/plugins/__init__.py @@ -9,6 +9,7 @@ import logging import tempfile import copy from .. import models +from ..api import API_PARAMS logger = logging.getLogger(__name__) @@ -117,3 +118,40 @@ class ShelfMixin(object): if hasattr(self, '_sh') and self._sh is not None: with open(self.shelf_file, 'wb') as f: pickle.dump(self._sh, f) + + +default_plugin_type = API_PARAMS['plugin_type']['default'] + + +def pfilter(plugins, **kwargs): + """ Filter plugins by different criteria """ + if isinstance(plugins, models.Plugins): + plugins = plugins.plugins + elif isinstance(plugins, dict): + plugins = plugins.values() + ptype = kwargs.pop('plugin_type', default_plugin_type) + logger.debug('#' * 100) + logger.debug('ptype {}'.format(ptype)) + if ptype: + try: + ptype = ptype[0].upper() + ptype[1:] + pclass = globals()[ptype] + logger.debug('Class: {}'.format(pclass)) + candidates = filter(lambda x: isinstance(x, pclass), + plugins) + except KeyError: + raise models.Error('{} is not a valid type'.format(ptype)) + else: + candidates = plugins + + logger.debug(candidates) + + def matches(plug): + res = all(getattr(plug, k, None) == v for (k, v) in kwargs.items()) + logger.debug( + "matching {} with {}: {}".format(plug.name, kwargs, res)) + return res + + if kwargs: + candidates = filter(matches, candidates) + return {p.name: p for p in candidates} diff --git a/tests/test_client.py b/tests/test_client.py index c94af96..90ad7fa 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,18 +4,21 @@ try: except ImportError: from mock import patch +import json + from senpy.client import Client -from senpy.models import Results, Error +from senpy.models import Results, Plugins, Error +from senpy.plugins import AnalysisPlugin, default_plugin_type class Call(dict): def __init__(self, obj): - self.obj = obj.jsonld() + self.obj = obj.serialize() self.status_code = 200 self.content = self.json() def json(self): - return self.obj + return json.loads(self.obj) class ModelsTest(TestCase): @@ -44,3 +47,19 @@ class ModelsTest(TestCase): method='GET', params={'input': 'hello', 'algorithm': 'NONEXISTENT'}) + + def test_plugins(self): + endpoint = 'http://dummy/' + client = Client(endpoint) + plugins = Plugins() + p1 = AnalysisPlugin({'name': 'AnalysisP1', 'version': 0, 'description': 'No'}) + plugins.plugins = [p1, ] + success = Call(plugins) + with patch('requests.request', return_value=success) as patched: + response = client.plugins() + assert isinstance(response, dict) + assert len(response) == 1 + assert "AnalysisP1" in response + patched.assert_called_with( + url=endpoint + '/plugins', method='GET', + params={'plugin_type': default_plugin_type}) diff --git a/tests/test_models.py b/tests/test_models.py index f667859..c0c7034 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -170,7 +170,7 @@ class ModelsTest(TestCase): def test_single_plugin(self): """A response with a single plugin should still return a list""" plugs = Plugins() - p = Plugin({'id': str(0), + p = Plugin({'id': str(1), 'version': 0, 'description': 'dummy'}) plugs.plugins.append(p) From ef40bdb545b7c23b86be405177b96758bbca6e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Fernando=20S=C3=A1nchez?= Date: Mon, 10 Apr 2017 16:36:43 +0200 Subject: [PATCH 5/7] Replace gevent with tornado Closes #28 Added: * Async test (still missing one that includes the IOLoop) * Async plugin under tests. To manually try async functionalities: ``` senpy -f tests/ ``` --- requirements.txt | 2 +- senpy/__main__.py | 14 +++++++------ senpy/extensions.py | 2 ++ tests/plugins/async_plugin/asyncplugin.py | 21 ++++++++++++++++++++ tests/plugins/async_plugin/asyncplugin.senpy | 8 ++++++++ tests/test_extensions.py | 13 +++++++++++- 6 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 tests/plugins/async_plugin/asyncplugin.py create mode 100644 tests/plugins/async_plugin/asyncplugin.senpy diff --git a/requirements.txt b/requirements.txt index 0dcc894..db3c57b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ Flask>=0.10.1 requests>=2.4.1 -gevent>=1.1rc4 +tornado>=4.4.3 PyLD>=0.6.5 six future diff --git a/senpy/__main__.py b/senpy/__main__.py index dd711e4..4358174 100644 --- a/senpy/__main__.py +++ b/senpy/__main__.py @@ -22,15 +22,16 @@ the server. from flask import Flask from senpy.extensions import Senpy -from gevent.wsgi import WSGIServer -from gevent.monkey import patch_all +from tornado.wsgi import WSGIContainer +from tornado.httpserver import HTTPServer +from tornado.ioloop import IOLoop + + import logging import os import argparse import senpy -patch_all(thread=False) - SERVER_PORT = os.environ.get("PORT", 5000) @@ -92,9 +93,10 @@ def main(): print('Server running on port %s:%d. Ctrl+C to quit' % (args.host, args.port)) if not app.debug: - http_server = WSGIServer((args.host, args.port), app) + http_server = HTTPServer(WSGIContainer(app)) + http_server.listen(args.port, address=args.host) try: - http_server.serve_forever() + IOLoop.instance().start() except KeyboardInterrupt: print('Bye!') http_server.stop() diff --git a/senpy/extensions.py b/senpy/extensions.py index 36e2654..5989603 100644 --- a/senpy/extensions.py +++ b/senpy/extensions.py @@ -303,6 +303,7 @@ class Senpy(object): else: th = Thread(target=act) th.start() + return th def deactivate_plugin(self, plugin_name, sync=False): try: @@ -327,6 +328,7 @@ class Senpy(object): else: th = Thread(target=deact) th.start() + return th @classmethod def validate_info(cls, info): diff --git a/tests/plugins/async_plugin/asyncplugin.py b/tests/plugins/async_plugin/asyncplugin.py new file mode 100644 index 0000000..345ff2d --- /dev/null +++ b/tests/plugins/async_plugin/asyncplugin.py @@ -0,0 +1,21 @@ +from senpy.plugins import AnalysisPlugin + +import multiprocessing + + +class AsyncPlugin(AnalysisPlugin): + def _train(self, process_number): + return process_number + + def _do_async(self, num_processes): + with multiprocessing.Pool(processes=num_processes) as pool: + values = pool.map(self._train, range(num_processes)) + return values + + def activate(self): + self.value = self._do_async(4) + + def analyse_entry(self, entry, params): + values = self._do_async(2) + entry.async_values = values + yield entry diff --git a/tests/plugins/async_plugin/asyncplugin.senpy b/tests/plugins/async_plugin/asyncplugin.senpy new file mode 100644 index 0000000..8c71849 --- /dev/null +++ b/tests/plugins/async_plugin/asyncplugin.senpy @@ -0,0 +1,8 @@ +--- +name: Async +module: asyncplugin +description: I am async +author: "@balkian" +version: '0.1' +async: true +extra_params: {} \ No newline at end of file diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 0523789..067f697 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -167,7 +167,7 @@ class ExtensionsTest(TestCase): assert len(senpy.plugins) > 1 def test_convert_emotions(self): - self.senpy.activate_all() + self.senpy.activate_all(sync=True) plugin = Plugin({ 'id': 'imaginary', 'onyx:usesEmotionModel': 'emoml:fsre-dimensions' @@ -205,3 +205,14 @@ class ExtensionsTest(TestCase): [plugin, ], params) assert len(r3.entries[0].emotions) == 1 + + # def test_async_plugin(self): + # """ We should accept multiprocessing plugins with async=False""" + # thread1 = self.senpy.activate_plugin("Async", sync=False) + # thread1.join(timeout=1) + # assert len(self.senpy.plugins['Async'].value) == 4 + + # resp = self.senpy.analyse(input='nothing', algorithm='Async') + + # assert len(resp.entries[0].async_values) == 2 + # self.senpy.activate_plugin("Async", sync=True) From e582ef07d4b9f537e31d31c1546df870a2bd361c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Fernando=20S=C3=A1nchez?= Date: Mon, 10 Apr 2017 20:14:40 +0200 Subject: [PATCH 6/7] Fix multiprocessing tests in python2.7 Closes #28 for python 2. Apparently, process pools are not contexts in python 2.7. On the other hand, in py2 you cannot pickle instance methods, so you have to implement Pool tasks as independent functions. --- tests/plugins/async_plugin/asyncplugin.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/plugins/async_plugin/asyncplugin.py b/tests/plugins/async_plugin/asyncplugin.py index 345ff2d..976e6c8 100644 --- a/tests/plugins/async_plugin/asyncplugin.py +++ b/tests/plugins/async_plugin/asyncplugin.py @@ -3,13 +3,15 @@ from senpy.plugins import AnalysisPlugin import multiprocessing -class AsyncPlugin(AnalysisPlugin): - def _train(self, process_number): - return process_number +def _train(process_number): + return process_number + +class AsyncPlugin(AnalysisPlugin): def _do_async(self, num_processes): - with multiprocessing.Pool(processes=num_processes) as pool: - values = pool.map(self._train, range(num_processes)) + pool = multiprocessing.Pool(processes=num_processes) + values = pool.map(_train, range(num_processes)) + return values def activate(self): From 13cefbedfb15bcea371212a665ff42cf3cdb493d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Fernando=20S=C3=A1nchez?= Date: Mon, 10 Apr 2017 20:38:12 +0200 Subject: [PATCH 7/7] Clean dev containers in makefile --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 96633ce..91fcd5c 100644 --- a/Makefile +++ b/Makefile @@ -73,8 +73,8 @@ pip_test: $(addprefix pip_test-,$(PYVERSIONS)) clean: @docker ps -a | awk '/$(REPO)\/$(NAME)/{ split($$2, vers, "-"); if(vers[0] != "${VERSION}"){ print $$1;}}' | xargs docker rm -v 2>/dev/null|| true @docker images | awk '/$(REPO)\/$(NAME)/{ split($$2, vers, "-"); if(vers[0] != "${VERSION}"){ print $$1":"$$2;}}' | xargs docker rmi 2>/dev/null|| true - @docker rmi $(NAME)-dev 2>/dev/null || true - + @docker stop $(addprefix $(NAME)-dev,$(PYVERSIONS)) 2>/dev/null || true + @docker rm $(addprefix $(NAME)-dev,$(PYVERSIONS)) 2>/dev/null || true git_commit: git commit -a