Merge pull request #69 from hartym/master

Toward 0.3
This commit is contained in:
Romain Dorgueil
2017-05-22 01:50:17 -07:00
committed by GitHub
67 changed files with 1464 additions and 706 deletions

1
.gitignore vendored
View File

@ -37,6 +37,7 @@
/examples/private /examples/private
/htmlcov/ /htmlcov/
/sdist/ /sdist/
/tags
celerybeat-schedule celerybeat-schedule
parts/ parts/
pip-delete-this-directory.txt pip-delete-this-directory.txt

View File

@ -1,7 +1,7 @@
# This file has been auto-generated. # This file has been auto-generated.
# All changes will be lost, see Projectfile. # All changes will be lost, see Projectfile.
# #
# Updated at 2017-05-02 20:38:38.468986 # Updated at 2017-05-03 18:02:59.359160
PACKAGE ?= bonobo PACKAGE ?= bonobo
PYTHON ?= $(shell which python) PYTHON ?= $(shell which python)

View File

@ -1,10 +1,10 @@
import warnings import warnings
from bonobo.basics import Limit, PrettyPrint, Tee, count, identity, noop, pprint from bonobo.structs import Bag, Graph, Token
from bonobo.nodes import CsvReader, CsvWriter, FileReader, FileWriter, Filter, JsonReader, JsonWriter, Limit, \
PrettyPrint, Tee, count, identity, noop, pprint
from bonobo.strategies import create_strategy from bonobo.strategies import create_strategy
from bonobo.structs import Bag, Graph
from bonobo.util.objects import get_name from bonobo.util.objects import get_name
from bonobo.io import CsvReader, CsvWriter, FileReader, FileWriter, JsonReader, JsonWriter
__all__ = [] __all__ = []
@ -20,7 +20,7 @@ def register_api_group(*args):
@register_api @register_api
def run(graph, *chain, strategy=None, plugins=None, services=None): def run(graph, strategy=None, plugins=None, services=None):
""" """
Main entry point of bonobo. It takes a graph and creates all the necessary plumbery around to execute it. Main entry point of bonobo. It takes a graph and creates all the necessary plumbery around to execute it.
@ -40,21 +40,16 @@ def run(graph, *chain, strategy=None, plugins=None, services=None):
:param dict services: The implementations of services this graph will use. :param dict services: The implementations of services this graph will use.
:return bonobo.execution.graph.GraphExecutionContext: :return bonobo.execution.graph.GraphExecutionContext:
""" """
if len(chain):
warnings.warn('DEPRECATED. You should pass a Graph instance instead of a chain.')
from bonobo import Graph
graph = Graph(graph, *chain)
strategy = create_strategy(strategy) strategy = create_strategy(strategy)
plugins = plugins or [] plugins = plugins or []
if _is_interactive_console(): if _is_interactive_console(): # pragma: no cover
from bonobo.ext.console import ConsoleOutputPlugin from bonobo.ext.console import ConsoleOutputPlugin
if ConsoleOutputPlugin not in plugins: if ConsoleOutputPlugin not in plugins:
plugins.append(ConsoleOutputPlugin) plugins.append(ConsoleOutputPlugin)
if _is_jupyter_notebook(): if _is_jupyter_notebook(): # pragma: no cover
from bonobo.ext.jupyter import JupyterOutputPlugin from bonobo.ext.jupyter import JupyterOutputPlugin
if JupyterOutputPlugin not in plugins: if JupyterOutputPlugin not in plugins:
plugins.append(JupyterOutputPlugin) plugins.append(JupyterOutputPlugin)
@ -63,7 +58,7 @@ def run(graph, *chain, strategy=None, plugins=None, services=None):
# bonobo.structs # bonobo.structs
register_api_group(Bag, Graph) register_api_group(Bag, Graph, Token)
# bonobo.strategies # bonobo.strategies
register_api(create_strategy) register_api(create_strategy)
@ -88,8 +83,15 @@ def open_fs(fs_url, *args, **kwargs):
return _open_fs(str(fs_url), *args, **kwargs) return _open_fs(str(fs_url), *args, **kwargs)
# bonobo.basics # bonobo.nodes
register_api_group( register_api_group(
CsvReader,
CsvWriter,
FileReader,
FileWriter,
Filter,
JsonReader,
JsonWriter,
Limit, Limit,
PrettyPrint, PrettyPrint,
Tee, Tee,
@ -99,9 +101,6 @@ register_api_group(
pprint, pprint,
) )
# bonobo.io
register_api_group(CsvReader, CsvWriter, FileReader, FileWriter, JsonReader, JsonWriter)
def _is_interactive_console(): def _is_interactive_console():
import sys import sys

View File

@ -1,5 +1,5 @@
from bonobo.config.configurables import Configurable from bonobo.config.configurables import Configurable
from bonobo.config.options import Option from bonobo.config.options import Option, Method
from bonobo.config.processors import ContextProcessor from bonobo.config.processors import ContextProcessor
from bonobo.config.services import Container, Service from bonobo.config.services import Container, Service
@ -8,5 +8,6 @@ __all__ = [
'Container', 'Container',
'ContextProcessor', 'ContextProcessor',
'Option', 'Option',
'Method',
'Service', 'Service',
] ]

View File

@ -1,5 +1,6 @@
from bonobo.config.options import Method, Option
from bonobo.config.processors import ContextProcessor from bonobo.config.processors import ContextProcessor
from bonobo.config.options import Option from bonobo.errors import ConfigurationError
__all__ = [ __all__ = [
'Configurable', 'Configurable',
@ -17,6 +18,7 @@ class ConfigurableMeta(type):
cls.__options__ = {} cls.__options__ = {}
cls.__positional_options__ = [] cls.__positional_options__ = []
cls.__processors__ = [] cls.__processors__ = []
cls.__wrappable__ = None
for typ in cls.__mro__: for typ in cls.__mro__:
for name, value in typ.__dict__.items(): for name, value in typ.__dict__.items():
@ -26,8 +28,17 @@ class ConfigurableMeta(type):
else: else:
if not value.name: if not value.name:
value.name = name value.name = name
if isinstance(value, Method):
if cls.__wrappable__:
raise ConfigurationError(
'Cannot define more than one "Method" option in a configurable. That may change in the future.'
)
cls.__wrappable__ = name
if not name in cls.__options__: if not name in cls.__options__:
cls.__options__[name] = value cls.__options__[name] = value
if value.positional: if value.positional:
cls.__positional_options__.append(name) cls.__positional_options__.append(name)
@ -43,6 +54,12 @@ class Configurable(metaclass=ConfigurableMeta):
""" """
def __new__(cls, *args, **kwargs):
if cls.__wrappable__ and len(args) == 1 and hasattr(args[0], '__call__'):
return type(args[0].__name__, (cls, ), {cls.__wrappable__: args[0]})
return super(Configurable, cls).__new__(cls)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__() super().__init__()
@ -58,9 +75,11 @@ class Configurable(metaclass=ConfigurableMeta):
# transform positional arguments in keyword arguments if possible. # transform positional arguments in keyword arguments if possible.
position = 0 position = 0
for positional_option in self.__positional_options__: for positional_option in self.__positional_options__:
if positional_option in missing: if len(args) <= position:
break
kwargs[positional_option] = args[position] kwargs[positional_option] = args[position]
position += 1 position += 1
if positional_option in missing:
missing.remove(positional_option) missing.remove(positional_option)
# complain if there are still missing options. # complain if there are still missing options.
@ -85,3 +104,11 @@ class Configurable(metaclass=ConfigurableMeta):
# set option values. # set option values.
for name, value in kwargs.items(): for name, value in kwargs.items():
setattr(self, name, value) setattr(self, name, value)
def __call__(self, *args, **kwargs):
""" You can implement a configurable callable behaviour by implemenenting the call(...) method. Of course, it is also backward compatible with legacy __call__ override.
"""
return self.call(*args, **kwargs)
def call(self, *args, **kwargs):
raise AbstractError('Not implemented.')

View File

@ -1,8 +1,51 @@
class Option: class Option:
""" """
An Option is a descriptor for a required or optional parameter of a Configurable. An Option is a descriptor for Configurable's parameters.
.. attribute:: type
Option type allows to provide a callable used to cast, clean or validate the option value. If not provided, or
None, the option's value will be the exact value user provided.
(default: None)
.. attribute:: required
If an option is required, an error will be raised if no value is provided (at runtime). If it is not, option
will have the default value if user does not override it at runtime.
(default: False)
.. attribute:: positional
If this is true, it'll be possible to provide the option value as a positional argument. Otherwise, it must
be provided as a keyword argument.
(default: False)
.. attribute:: default
Default value for non-required options.
(default: None)
Example:
.. code-block:: python
from bonobo.config import Configurable, Option
class Example(Configurable):
title = Option(str, required=True, positional=True)
keyword = Option(str, default='foo')
def call(self, s):
return self.title + ': ' + s + ' (' + self.keyword + ')'
example = Example('hello', keyword='bar')
""" """
_creation_counter = 0 _creation_counter = 0
def __init__(self, type=None, *, required=False, positional=False, default=None): def __init__(self, type=None, *, required=False, positional=False, default=None):
@ -16,13 +59,66 @@ class Option:
self._creation_counter = Option._creation_counter self._creation_counter = Option._creation_counter
Option._creation_counter += 1 Option._creation_counter += 1
def get_default(self):
return self.default() if callable(self.default) else self.default
def __get__(self, inst, typ): def __get__(self, inst, typ):
if not self.name in inst.__options_values__: if not self.name in inst.__options_values__:
inst.__options_values__[self.name] = self.get_default() inst.__options_values__[self.name] = self.get_default()
return inst.__options_values__[self.name] return inst.__options_values__[self.name]
def __set__(self, inst, value): def __set__(self, inst, value):
inst.__options_values__[self.name] = self.clean(value)
def clean(self, value):
return self.type(value) if self.type else value
def get_default(self):
return self.default() if callable(self.default) else self.default
class Method(Option):
"""
A Method is a special callable-valued option, that can be used in three different ways (but for same purpose).
* Like a normal option, the value can be provided to the Configurable constructor.
>>> from bonobo.config import Configurable, Method
>>> class MethodExample(Configurable):
... handler = Method()
>>> example1 = MethodExample(handler=str.upper)
* It can be used by a child class that overrides the Method with a normal method.
>>> class ChildMethodExample(MethodExample):
... def handler(self, s: str):
... return s.upper()
>>> example2 = ChildMethodExample()
* Finally, it also enables the class to be used as a decorator, to generate a subclass providing the Method a value.
>>> @MethodExample
... def OtherChildMethodExample(s):
... return s.upper()
>>> example3 = OtherChildMethodExample()
"""
def __init__(self):
super().__init__(None, required=False)
def __get__(self, inst, typ):
if not self.name in inst.__options_values__:
inst.__options_values__[self.name] = getattr(inst, self.name)
return inst.__options_values__[self.name]
def __set__(self, inst, value):
if isinstance(value, str):
raise ValueError('should be callable')
inst.__options_values__[self.name] = self.type(value) if self.type else value inst.__options_values__[self.name] = self.type(value) if self.type else value
def clean(self, value):
if not hasattr(value, '__call__'):
raise ValueError('{} value must be callable.'.format(type(self).__name__))
return value

View File

@ -1,16 +1,42 @@
import functools
import types import types
from collections import Iterable
from bonobo.util.compat import deprecated_alias, deprecated from contextlib import contextmanager
from bonobo.config.options import Option from bonobo.config.options import Option
from bonobo.util.compat import deprecated_alias
from bonobo.util.iterators import ensure_tuple from bonobo.util.iterators import ensure_tuple
_CONTEXT_PROCESSORS_ATTR = '__processors__' _CONTEXT_PROCESSORS_ATTR = '__processors__'
class ContextProcessor(Option): class ContextProcessor(Option):
"""
A ContextProcessor is a kind of transformation decorator that can setup and teardown a transformation and runtime
related dependencies, at the execution level.
It works like a yielding context manager, and is the recommended way to setup and teardown objects you'll need
in the context of one execution. It's the way to overcome the stateless nature of transformations.
The yielded values will be passed as positional arguments to the next context processors (order do matter), and
finally to the __call__ method of the transformation.
Warning: this may change for a similar but simpler implementation, don't relly too much on it (yet).
Example:
>>> from bonobo.config import Configurable
>>> from bonobo.util.objects import ValueHolder
>>> class Counter(Configurable):
... @ContextProcessor
... def counter(self, context):
... yield ValueHolder(0)
...
... def __call__(self, counter, *args, **kwargs):
... counter += 1
... yield counter.get()
"""
@property @property
def __name__(self): def __name__(self):
return self.func.__name__ return self.func.__name__
@ -49,6 +75,15 @@ class ContextCurrifier:
self.wrapped = wrapped self.wrapped = wrapped
self.context = tuple(initial_context) self.context = tuple(initial_context)
self._stack = [] self._stack = []
self._stack_values = []
def __iter__(self):
yield from self.wrapped
def __call__(self, *args, **kwargs):
if not callable(self.wrapped) and isinstance(self.wrapped, Iterable):
return self.__iter__()
return self.wrapped(*self.context, *args, **kwargs)
def setup(self, *context): def setup(self, *context):
if len(self._stack): if len(self._stack):
@ -56,19 +91,17 @@ class ContextCurrifier:
for processor in resolve_processors(self.wrapped): for processor in resolve_processors(self.wrapped):
_processed = processor(self.wrapped, *context, *self.context) _processed = processor(self.wrapped, *context, *self.context)
_append_to_context = next(_processed) _append_to_context = next(_processed)
self._stack_values.append(_append_to_context)
if _append_to_context is not None: if _append_to_context is not None:
self.context += ensure_tuple(_append_to_context) self.context += ensure_tuple(_append_to_context)
self._stack.append(_processed) self._stack.append(_processed)
def __call__(self, *args, **kwargs):
return self.wrapped(*self.context, *args, **kwargs)
def teardown(self): def teardown(self):
while len(self._stack): while len(self._stack):
processor = self._stack.pop() processor = self._stack.pop()
try: try:
# todo yield from ? how to ? # todo yield from ? how to ?
next(processor) processor.send(self._stack_values.pop())
except StopIteration as exc: except StopIteration as exc:
# This is normal, and wanted. # This is normal, and wanted.
pass pass
@ -76,40 +109,22 @@ class ContextCurrifier:
# No error ? We should have had StopIteration ... # No error ? We should have had StopIteration ...
raise RuntimeError('Context processors should not yield more than once.') raise RuntimeError('Context processors should not yield more than once.')
@contextmanager
@deprecated def as_contextmanager(self, *context):
def add_context_processor(cls_or_func, context_processor):
getattr(cls_or_func, _CONTEXT_PROCESSORS_ATTR).append(context_processor)
@deprecated
def contextual(cls_or_func):
""" """
Make sure an element has the context processors collection. Convenience method to use it as a contextmanager, mostly for test purposes.
:param cls_or_func: Example:
>>> with ContextCurrifier(node).as_contextmanager(context) as stack:
... stack()
:param context:
:return:
""" """
if not add_context_processor.__name__ in cls_or_func.__dict__: self.setup(*context)
setattr(cls_or_func, add_context_processor.__name__, functools.partial(add_context_processor, cls_or_func)) yield self
self.teardown()
if isinstance(cls_or_func, types.FunctionType):
try:
getattr(cls_or_func, _CONTEXT_PROCESSORS_ATTR)
except AttributeError:
setattr(cls_or_func, _CONTEXT_PROCESSORS_ATTR, [])
return cls_or_func
if not _CONTEXT_PROCESSORS_ATTR in cls_or_func.__dict__:
setattr(cls_or_func, _CONTEXT_PROCESSORS_ATTR, [])
_processors = getattr(cls_or_func, _CONTEXT_PROCESSORS_ATTR)
for processor in cls_or_func.__dict__.values():
if isinstance(processor, ContextProcessor):
_processors.append(processor)
# This is needed for python 3.5, python 3.6 should be fine, but it's considered an implementation detail.
_processors.sort(key=lambda proc: proc._creation_counter)
return cls_or_func
def resolve_processors(mixed): def resolve_processors(mixed):

View File

@ -40,6 +40,10 @@ class Service(Option):
The main goal is not to tie transformations to actual dependencies, so the same can be run in different contexts The main goal is not to tie transformations to actual dependencies, so the same can be run in different contexts
(stages like preprod, prod, or tenants like client1, client2, or anything you want). (stages like preprod, prod, or tenants like client1, client2, or anything you want).
.. attribute:: name
Service name will be used to retrieve the implementation at runtime.
""" """
def __init__(self, name): def __init__(self, name):

View File

@ -1 +0,0 @@
""" Core required libraries. """

View File

@ -52,3 +52,7 @@ class ValidationError(RuntimeError):
class ProhibitedOperationError(RuntimeError): class ProhibitedOperationError(RuntimeError):
pass pass
class ConfigurationError(Exception):
pass

View File

@ -0,0 +1,23 @@
def require(package, requirement=None):
requirement = requirement or package
try:
return __import__(package)
except ImportError:
from colorama import Fore, Style
print(
Fore.YELLOW,
'This example requires the {!r} package. Install it using:'.
format(requirement),
Style.RESET_ALL,
sep=''
)
print()
print(
Fore.YELLOW,
' $ pip install {!s}'.format(requirement),
Style.RESET_ALL,
sep=''
)
print()
raise

View File

@ -1,182 +1,182 @@
{"Ext\u00e9rieur Quai": "5, rue d'Alsace, 75010 Paris, France", {"O q de poule": "53 rue du ruisseau, 75018 Paris, France",
"Le Sully": "6 Bd henri IV, 75004 Paris, France", "Le chantereine": "51 Rue Victoire, 75009 Paris, France",
"O q de poule": "53 rue du ruisseau, 75018 Paris, France", "La Caravane": "Rue de la Fontaine au Roi, 75011 Paris, France",
"Le Pas Sage": "1 Passage du Grand Cerf, 75002 Paris, France", "Le Pas Sage": "1 Passage du Grand Cerf, 75002 Paris, France",
"La Renaissance": "112 Rue Championnet, 75018 Paris, France", "La Renaissance": "112 Rue Championnet, 75018 Paris, France",
"Le chantereine": "51 Rue Victoire, 75009 Paris, France", "Ext\u00e9rieur Quai": "5, rue d'Alsace, 75010 Paris, France",
"Le M\u00fcller": "11 rue Feutrier, 75018 Paris, France", "Le Reynou": "2 bis quai de la m\u00e9gisserie, 75001 Paris, France",
"les montparnos": "65 boulevard Pasteur, 75015 Paris, France",
"Le bistrot de Ma\u00eblle et Augustin": "42 rue coquill\u00e8re, 75001 Paris, France",
"D\u00e9d\u00e9 la frite": "52 rue Notre-Dame des Victoires, 75002 Paris, France",
"Assaporare Dix sur Dix": "75, avenue Ledru-Rollin, 75012 Paris, France",
"Cardinal Saint-Germain": "11 boulevard Saint-Germain, 75005 Paris, France",
"Caf\u00e9 antoine": "17 rue Jean de la Fontaine, 75016 Paris, France",
"Au cerceau d'or": "129 boulevard sebastopol, 75002 Paris, France",
"Le Chaumontois": "12 rue Armand Carrel, 75018 Paris, France",
"Aux cadrans": "21 ter boulevard Diderot, 75012 Paris, France",
"Le Saint Jean": "23 rue des abbesses, 75018 Paris, France",
"Le Square": "31 rue Saint-Dominique, 75007 Paris, France",
"Les Arcades": "61 rue de Ponthieu, 75008 Paris, France",
"Caf\u00e9 Lea": "5 rue Claude Bernard, 75005 Paris, France",
"Le Bellerive": "71 quai de Seine, 75019 Paris, France",
"La Bauloise": "36 rue du hameau, 75015 Paris, France",
"Le Dellac": "14 rue Rougemont, 75009 Paris, France",
"Le Bosquet": "46 avenue Bosquet, 75007 Paris, France",
"Le Sully": "6 Bd henri IV, 75004 Paris, France",
"Le Felteu": "1 rue Pecquay, 75004 Paris, France",
"Le drapeau de la fidelit\u00e9": "21 rue Copreaux, 75015 Paris, France", "Le drapeau de la fidelit\u00e9": "21 rue Copreaux, 75015 Paris, France",
"Le caf\u00e9 des amis": "125 rue Blomet, 75015 Paris, France", "Le caf\u00e9 des amis": "125 rue Blomet, 75015 Paris, France",
"Le Caf\u00e9 Livres": "10 rue Saint Martin, 75004 Paris, France",
"Le Bosquet": "46 avenue Bosquet, 75007 Paris, France",
"Le Brio": "216, rue Marcadet, 75018 Paris, France",
"Le Kleemend's": "34 avenue Pierre Mend\u00e8s-France, 75013 Paris, France", "Le Kleemend's": "34 avenue Pierre Mend\u00e8s-France, 75013 Paris, France",
"Caf\u00e9 Pierre": "202 rue du faubourg st antoine, 75012 Paris, France", "Caf\u00e9 Pierre": "202 rue du faubourg st antoine, 75012 Paris, France",
"Les Arcades": "61 rue de Ponthieu, 75008 Paris, France", "Le M\u00fcller": "11 rue Feutrier, 75018 Paris, France",
"Le Square": "31 rue Saint-Dominique, 75007 Paris, France", "Le Caf\u00e9 Livres": "10 rue Saint Martin, 75004 Paris, France",
"Assaporare Dix sur Dix": "75, avenue Ledru-Rollin, 75012 Paris, France",
"Au cerceau d'or": "129 boulevard sebastopol, 75002 Paris, France",
"Aux cadrans": "21 ter boulevard Diderot, 75012 Paris, France",
"Caf\u00e9 antoine": "17 rue Jean de la Fontaine, 75016 Paris, France",
"Caf\u00e9 Lea": "5 rue Claude Bernard, 75005 Paris, France",
"Cardinal Saint-Germain": "11 boulevard Saint-Germain, 75005 Paris, France",
"D\u00e9d\u00e9 la frite": "52 rue Notre-Dame des Victoires, 75002 Paris, France",
"La Bauloise": "36 rue du hameau, 75015 Paris, France",
"Le Bellerive": "71 quai de Seine, 75019 Paris, France",
"Le bistrot de Ma\u00eblle et Augustin": "42 rue coquill\u00e8re, 75001 Paris, France",
"Le Dellac": "14 rue Rougemont, 75009 Paris, France",
"Le Felteu": "1 rue Pecquay, 75004 Paris, France",
"Le Reynou": "2 bis quai de la m\u00e9gisserie, 75001 Paris, France",
"Le Saint Jean": "23 rue des abbesses, 75018 Paris, France",
"les montparnos": "65 boulevard Pasteur, 75015 Paris, France",
"Drole d'endroit pour une rencontre": "58 rue de Montorgueil, 75002 Paris, France",
"Le pari's caf\u00e9": "104 rue caulaincourt, 75018 Paris, France",
"Le Poulailler": "60 rue saint-sabin, 75011 Paris, France",
"L'Assassin": "99 rue Jean-Pierre Timbaud, 75011 Paris, France",
"l'Usine": "1 rue d'Avron, 75020 Paris, France",
"La Bricole": "52 rue Liebniz, 75018 Paris, France",
"le ronsard": "place maubert, 75005 Paris, France",
"Face Bar": "82 rue des archives, 75003 Paris, France",
"American Kitchen": "49 rue bichat, 75010 Paris, France",
"La Marine": "55 bis quai de valmy, 75010 Paris, France",
"Le Bloc": "21 avenue Brochant, 75017 Paris, France",
"La Recoleta au Manoir": "229 avenue Gambetta, 75020 Paris, France",
"Le Pareloup": "80 Rue Saint-Charles, 75015 Paris, France",
"La Brasserie Gait\u00e9": "3 rue de la Gait\u00e9, 75014 Paris, France",
"Caf\u00e9 Zen": "46 rue Victoire, 75009 Paris, France",
"O'Breizh": "27 rue de Penthi\u00e8vre, 75008 Paris, France",
"Le Petit Choiseul": "23 rue saint augustin, 75002 Paris, France",
"Invitez vous chez nous": "7 rue Ep\u00e9e de Bois, 75005 Paris, France",
"La Cordonnerie": "142 Rue Saint-Denis 75002 Paris, 75002 Paris, France", "La Cordonnerie": "142 Rue Saint-Denis 75002 Paris, 75002 Paris, France",
"Invitez vous chez nous": "7 rue Ep\u00e9e de Bois, 75005 Paris, France",
"Au bon coin": "49 rue des Cloys, 75018 Paris, France",
"La Br\u00fblerie des Ternes": "111 rue mouffetard, 75005 Paris, France",
"Le Petit Choiseul": "23 rue saint augustin, 75002 Paris, France",
"O'Breizh": "27 rue de Penthi\u00e8vre, 75008 Paris, France",
"Le Supercoin": "3, rue Baudelique, 75018 Paris, France", "Le Supercoin": "3, rue Baudelique, 75018 Paris, France",
"Populettes": "86 bis rue Riquet, 75018 Paris, France", "Populettes": "86 bis rue Riquet, 75018 Paris, France",
"Au bon coin": "49 rue des Cloys, 75018 Paris, France",
"Le Couvent": "69 rue Broca, 75013 Paris, France", "Le Couvent": "69 rue Broca, 75013 Paris, France",
"La Br\u00fblerie des Ternes": "111 rue mouffetard, 75005 Paris, France", "Caf\u00e9 Zen": "46 rue Victoire, 75009 Paris, France",
"L'\u00c9cir": "59 Boulevard Saint-Jacques, 75014 Paris, France",
"Le Chat bossu": "126, rue du Faubourg Saint Antoine, 75012 Paris, France", "Le Chat bossu": "126, rue du Faubourg Saint Antoine, 75012 Paris, France",
"Denfert caf\u00e9": "58 boulvevard Saint Jacques, 75014 Paris, France",
"Le Caf\u00e9 frapp\u00e9": "95 rue Montmartre, 75002 Paris, France",
"La Perle": "78 rue vieille du temple, 75003 Paris, France",
"Le Descartes": "1 rue Thouin, 75005 Paris, France",
"Bagels & Coffee Corner": "Place de Clichy, 75017 Paris, France",
"Le petit club": "55 rue de la tombe Issoire, 75014 Paris, France", "Le petit club": "55 rue de la tombe Issoire, 75014 Paris, France",
"Le Plein soleil": "90 avenue Parmentier, 75011 Paris, France",
"Le Relais Haussmann": "146, boulevard Haussmann, 75008 Paris, France", "Le Relais Haussmann": "146, boulevard Haussmann, 75008 Paris, France",
"Le Malar": "88 rue Saint-Dominique, 75007 Paris, France", "Denfert caf\u00e9": "58 boulvevard Saint Jacques, 75014 Paris, France",
"Au panini de la place": "47 rue Belgrand, 75020 Paris, France", "Bagels & Coffee Corner": "Place de Clichy, 75017 Paris, France",
"Le Village": "182 rue de Courcelles, 75017 Paris, France", "Le Plein soleil": "90 avenue Parmentier, 75011 Paris, France",
"Pause Caf\u00e9": "41 rue de Charonne, 75011 Paris, France", "La Perle": "78 rue vieille du temple, 75003 Paris, France",
"Le Pure caf\u00e9": "14 rue Jean Mac\u00e9, 75011 Paris, France", "Le Caf\u00e9 frapp\u00e9": "95 rue Montmartre, 75002 Paris, France",
"Extra old caf\u00e9": "307 fg saint Antoine, 75011 Paris, France", "L'\u00c9cir": "59 Boulevard Saint-Jacques, 75014 Paris, France",
"Chez Fafa": "44 rue Vinaigriers, 75010 Paris, France", "Le Descartes": "1 rue Thouin, 75005 Paris, France",
"En attendant l'or": "3 rue Faidherbe, 75011 Paris, France",
"Br\u00fblerie San Jos\u00e9": "30 rue des Petits-Champs, 75002 Paris, France", "Br\u00fblerie San Jos\u00e9": "30 rue des Petits-Champs, 75002 Paris, France",
"Caf\u00e9 de la Mairie (du VIII)": "rue de Lisbonne, 75008 Paris, France", "Caf\u00e9 de la Mairie (du VIII)": "rue de Lisbonne, 75008 Paris, France",
"Au panini de la place": "47 rue Belgrand, 75020 Paris, France",
"Extra old caf\u00e9": "307 fg saint Antoine, 75011 Paris, France",
"En attendant l'or": "3 rue Faidherbe, 75011 Paris, France",
"Le Pure caf\u00e9": "14 rue Jean Mac\u00e9, 75011 Paris, France",
"Le Village": "182 rue de Courcelles, 75017 Paris, France",
"Le Malar": "88 rue Saint-Dominique, 75007 Paris, France",
"Pause Caf\u00e9": "41 rue de Charonne, 75011 Paris, France",
"Chez Fafa": "44 rue Vinaigriers, 75010 Paris, France",
"La Recoleta au Manoir": "229 avenue Gambetta, 75020 Paris, France",
"Le Pareloup": "80 Rue Saint-Charles, 75015 Paris, France",
"La Marine": "55 bis quai de valmy, 75010 Paris, France",
"American Kitchen": "49 rue bichat, 75010 Paris, France",
"Face Bar": "82 rue des archives, 75003 Paris, France",
"Le Bloc": "21 avenue Brochant, 75017 Paris, France",
"La Bricole": "52 rue Liebniz, 75018 Paris, France",
"le ronsard": "place maubert, 75005 Paris, France",
"l'Usine": "1 rue d'Avron, 75020 Paris, France",
"La Brasserie Gait\u00e9": "3 rue de la Gait\u00e9, 75014 Paris, France",
"Le General Beuret": "9 Place du General Beuret, 75015 Paris, France",
"Le Cap Bourbon": "1 rue Louis le Grand, 75002 Paris, France",
"Le Ragueneau": "202 rue Saint-Honor\u00e9, 75001 Paris, France",
"Le Germinal": "95 avenue Emile Zola, 75015 Paris, France",
"Caf\u00e9 Martin": "2 place Martin Nadaud, 75001 Paris, France", "Caf\u00e9 Martin": "2 place Martin Nadaud, 75001 Paris, France",
"Etienne": "14 rue Turbigo, Paris, 75001 Paris, France", "Etienne": "14 rue Turbigo, Paris, 75001 Paris, France",
"L'ing\u00e9nu": "184 bd Voltaire, 75011 Paris, France", "L'ing\u00e9nu": "184 bd Voltaire, 75011 Paris, France",
"L'Olive": "8 rue L'Olive, 75018 Paris, France",
"Le Biz": "18 rue Favart, 75002 Paris, France",
"Le Cap Bourbon": "1 rue Louis le Grand, 75002 Paris, France",
"Le General Beuret": "9 Place du General Beuret, 75015 Paris, France",
"Le Germinal": "95 avenue Emile Zola, 75015 Paris, France",
"Le Ragueneau": "202 rue Saint-Honor\u00e9, 75001 Paris, France",
"Le refuge": "72 rue lamarck, 75018 Paris, France", "Le refuge": "72 rue lamarck, 75018 Paris, France",
"Le Biz": "18 rue Favart, 75002 Paris, France",
"L'Olive": "8 rue L'Olive, 75018 Paris, France",
"Le sully": "13 rue du Faubourg Saint Denis, 75010 Paris, France", "Le sully": "13 rue du Faubourg Saint Denis, 75010 Paris, France",
"L'antre d'eux": "16 rue DE MEZIERES, 75006 Paris, France", "Drole d'endroit pour une rencontre": "58 rue de Montorgueil, 75002 Paris, France",
"Le bal du pirate": "60 rue des bergers, 75015 Paris, France", "L'Assassin": "99 rue Jean-Pierre Timbaud, 75011 Paris, France",
"zic zinc": "95 rue claude decaen, 75012 Paris, France", "Le pari's caf\u00e9": "104 rue caulaincourt, 75018 Paris, France",
"l'orillon bar": "35 rue de l'orillon, 75011 Paris, France", "Le Poulailler": "60 rue saint-sabin, 75011 Paris, France",
"Le Zazabar": "116 Rue de M\u00e9nilmontant, 75020 Paris, France", "Chai 33": "33 Cour Saint Emilion, 75012 Paris, France",
"L'In\u00e9vitable": "22 rue Linn\u00e9, 75005 Paris, France",
"Le Dunois": "77 rue Dunois, 75013 Paris, France",
"Ragueneau": "202 rue Saint Honor\u00e9, 75001 Paris, France",
"Le Caminito": "48 rue du Dessous des Berges, 75013 Paris, France",
"Epicerie Musicale": "55bis quai de Valmy, 75010 Paris, France",
"Le petit Bretonneau": "Le petit Bretonneau - \u00e0 l'int\u00e9rieur de l'H\u00f4pital, 75018 Paris, France",
"Le Centenaire": "104 rue amelot, 75011 Paris, France",
"La Montagne Sans Genevi\u00e8ve": "13 Rue du Pot de Fer, 75005 Paris, France",
"Les P\u00e8res Populaires": "46 rue de Buzenval, 75020 Paris, France",
"Cafe de grenelle": "188 rue de Grenelle, 75007 Paris, France",
"Le relais de la victoire": "73 rue de la Victoire, 75009 Paris, France",
"La chaumi\u00e8re gourmande": "Route de la Muette \u00e0 Neuilly",
"Club hippique du Jardin d\u2019Acclimatation": "75016 Paris, France",
"Le Chaumontois": "12 rue Armand Carrel, 75018 Paris, France",
"Caves populaires": "22 rue des Dames, 75017 Paris, France",
"Caprice caf\u00e9": "12 avenue Jean Moulin, 75014 Paris, France",
"Tamm Bara": "7 rue Clisson, 75013 Paris, France",
"L'anjou": "1 rue de Montholon, 75009 Paris, France",
"Caf\u00e9 dans l'aerogare Air France Invalides": "2 rue Robert Esnault Pelterie, 75007 Paris, France", "Caf\u00e9 dans l'aerogare Air France Invalides": "2 rue Robert Esnault Pelterie, 75007 Paris, France",
"Chez Prune": "36 rue Beaurepaire, 75010 Paris, France", "Les P\u00e8res Populaires": "46 rue de Buzenval, 75020 Paris, France",
"Au Vin Des Rues": "21 rue Boulard, 75014 Paris, France", "Epicerie Musicale": "55bis quai de Valmy, 75010 Paris, France",
"bistrot les timbr\u00e9s": "14 rue d'alleray, 75015 Paris, France", "Le relais de la victoire": "73 rue de la Victoire, 75009 Paris, France",
"Caf\u00e9 beauveau": "9 rue de Miromesnil, 75008 Paris, France", "Le Centenaire": "104 rue amelot, 75011 Paris, France",
"Caf\u00e9 Pistache": "9 rue des petits champs, 75001 Paris, France", "Caf\u00e9 Pistache": "9 rue des petits champs, 75001 Paris, France",
"La Cagnotte": "13 Rue Jean-Baptiste Dumay, 75020 Paris, France", "La Cagnotte": "13 Rue Jean-Baptiste Dumay, 75020 Paris, France",
"le 1 cinq": "172 rue de vaugirard, 75015 Paris, France", "Le bal du pirate": "60 rue des bergers, 75015 Paris, France",
"bistrot les timbr\u00e9s": "14 rue d'alleray, 75015 Paris, France",
"Le Killy Jen": "28 bis boulevard Diderot, 75012 Paris, France", "Le Killy Jen": "28 bis boulevard Diderot, 75012 Paris, France",
"le 1 cinq": "172 rue de vaugirard, 75015 Paris, France",
"Les Artisans": "106 rue Lecourbe, 75015 Paris, France", "Les Artisans": "106 rue Lecourbe, 75015 Paris, France",
"Peperoni": "83 avenue de Wagram, 75001 Paris, France", "Peperoni": "83 avenue de Wagram, 75001 Paris, France",
"le lutece": "380 rue de vaugirard, 75015 Paris, France", "Ragueneau": "202 rue Saint Honor\u00e9, 75001 Paris, France",
"Brasiloja": "16 rue Ganneron, 75018 Paris, France", "l'orillon bar": "35 rue de l'orillon, 75011 Paris, France",
"Rivolux": "16 rue de Rivoli, 75004 Paris, France", "zic zinc": "95 rue claude decaen, 75012 Paris, France",
"Chai 33": "33 Cour Saint Emilion, 75012 Paris, France", "L'In\u00e9vitable": "22 rue Linn\u00e9, 75005 Paris, France",
"L'europ\u00e9en": "21 Bis Boulevard Diderot, 75012 Paris, France", "Le Brio": "216, rue Marcadet, 75018 Paris, France",
"NoMa": "39 rue Notre Dame de Nazareth, 75003 Paris, France", "Le Dunois": "77 rue Dunois, 75013 Paris, France",
"O'Paris": "1 Rue des Envierges, 75020 Paris, France", "La Montagne Sans Genevi\u00e8ve": "13 Rue du Pot de Fer, 75005 Paris, France",
"Caf\u00e9 Clochette": "16 avenue Richerand, 75010 Paris, France", "Le Caminito": "48 rue du Dessous des Berges, 75013 Paris, France",
"Le petit Bretonneau": "Le petit Bretonneau - \u00e0 l'int\u00e9rieur de l'H\u00f4pital, 75018 Paris, France",
"La chaumi\u00e8re gourmande": "Route de la Muette \u00e0 Neuilly",
"Club hippique du Jardin d\u2019Acclimatation": "75016 Paris, France",
"Caprice caf\u00e9": "12 avenue Jean Moulin, 75014 Paris, France",
"Le Zazabar": "116 Rue de M\u00e9nilmontant, 75020 Paris, France",
"Caf\u00e9 beauveau": "9 rue de Miromesnil, 75008 Paris, France",
"Caves populaires": "22 rue des Dames, 75017 Paris, France",
"Cafe de grenelle": "188 rue de Grenelle, 75007 Paris, France",
"Au Vin Des Rues": "21 rue Boulard, 75014 Paris, France",
"L'antre d'eux": "16 rue DE MEZIERES, 75006 Paris, France",
"Chez Prune": "36 rue Beaurepaire, 75010 Paris, France",
"L'anjou": "1 rue de Montholon, 75009 Paris, France",
"Tamm Bara": "7 rue Clisson, 75013 Paris, France",
"La cantoche de Paname": "40 Boulevard Beaumarchais, 75011 Paris, France", "La cantoche de Paname": "40 Boulevard Beaumarchais, 75011 Paris, France",
"Le Saint Ren\u00e9": "148 Boulevard de Charonne, 75020 Paris, France", "Le Saint Ren\u00e9": "148 Boulevard de Charonne, 75020 Paris, France",
"Caf\u00e9 Clochette": "16 avenue Richerand, 75010 Paris, France",
"L'europ\u00e9en": "21 Bis Boulevard Diderot, 75012 Paris, France",
"Le BB (Bouchon des Batignolles)": "2 rue Lemercier, 75017 Paris, France",
"La Libert\u00e9": "196 rue du faubourg saint-antoine, 75012 Paris, France", "La Libert\u00e9": "196 rue du faubourg saint-antoine, 75012 Paris, France",
"Chez Rutabaga": "16 rue des Petits Champs, 75002 Paris, France", "Chez Rutabaga": "16 rue des Petits Champs, 75002 Paris, France",
"Le BB (Bouchon des Batignolles)": "2 rue Lemercier, 75017 Paris, France",
"La Brocante": "10 rue Rossini, 75009 Paris, France",
"Le Plomb du cantal": "3 rue Ga\u00eet\u00e9, 75014 Paris, France",
"Les caves populaires": "22 rue des Dames, 75017 Paris, France", "Les caves populaires": "22 rue des Dames, 75017 Paris, France",
"Chez Luna": "108 rue de M\u00e9nilmontant, 75020 Paris, France", "Le Plomb du cantal": "3 rue Ga\u00eet\u00e9, 75014 Paris, France",
"Le bar Fleuri": "1 rue du Plateau, 75019 Paris, France",
"Trois pi\u00e8ces cuisine": "101 rue des dames, 75017 Paris, France",
"Le Zinc": "61 avenue de la Motte Picquet, 75015 Paris, France",
"La cantine de Zo\u00e9": "136 rue du Faubourg poissonni\u00e8re, 75010 Paris, France",
"Les Vendangeurs": "6/8 rue Stanislas, 75006 Paris, France",
"L'avant comptoir": "3 carrefour de l'Od\u00e9on, 75006 Paris, France",
"Botak cafe": "1 rue Paul albert, 75018 Paris, France",
"le chateau d'eau": "67 rue du Ch\u00e2teau d'eau, 75010 Paris, France",
"Bistrot Saint-Antoine": "58 rue du Fbg Saint-Antoine, 75012 Paris, France", "Bistrot Saint-Antoine": "58 rue du Fbg Saint-Antoine, 75012 Paris, France",
"Trois pi\u00e8ces cuisine": "101 rue des dames, 75017 Paris, France",
"La Brocante": "10 rue Rossini, 75009 Paris, France",
"Le Zinc": "61 avenue de la Motte Picquet, 75015 Paris, France",
"Chez Oscar": "11/13 boulevard Beaumarchais, 75004 Paris, France", "Chez Oscar": "11/13 boulevard Beaumarchais, 75004 Paris, France",
"Le Fronton": "63 rue de Ponthieu, 75008 Paris, France",
"Le Piquet": "48 avenue de la Motte Picquet, 75015 Paris, France", "Le Piquet": "48 avenue de la Motte Picquet, 75015 Paris, France",
"Le Tournebride": "104 rue Mouffetard, 75005 Paris, France", "L'avant comptoir": "3 carrefour de l'Od\u00e9on, 75006 Paris, France",
"le chateau d'eau": "67 rue du Ch\u00e2teau d'eau, 75010 Paris, France",
"maison du vin": "52 rue des plantes, 75014 Paris, France", "maison du vin": "52 rue des plantes, 75014 Paris, France",
"Coffee Chope": "344Vrue Vaugirard, 75015 Paris, France", "Les Vendangeurs": "6/8 rue Stanislas, 75006 Paris, France",
"L'entrep\u00f4t": "157 rue Bercy 75012 Paris, 75012 Paris, France", "NoMa": "39 rue Notre Dame de Nazareth, 75003 Paris, France",
"Le caf\u00e9 Monde et M\u00e9dias": "Place de la R\u00e9publique, 75003 Paris, France", "Chez Luna": "108 rue de M\u00e9nilmontant, 75020 Paris, France",
"Caf\u00e9 rallye tournelles": "11 Quai de la Tournelle, 75005 Paris, France", "Le Tournebride": "104 rue Mouffetard, 75005 Paris, France",
"Brasserie le Morvan": "61 rue du ch\u00e2teau d'eau, 75010 Paris, France", "le lutece": "380 rue de vaugirard, 75015 Paris, France",
"Chez Miamophile": "6 rue M\u00e9lingue, 75019 Paris, France", "Le bar Fleuri": "1 rue du Plateau, 75019 Paris, France",
"La Caravane": "Rue de la Fontaine au Roi, 75011 Paris, France", "Le Fronton": "63 rue de Ponthieu, 75008 Paris, France",
"Panem": "18 rue de Crussol, 75011 Paris, France", "O'Paris": "1 Rue des Envierges, 75020 Paris, France",
"Petits Freres des Pauvres": "47 rue de Batignolles, 75017 Paris, France", "Rivolux": "16 rue de Rivoli, 75004 Paris, France",
"Caf\u00e9 Dupont": "198 rue de la Convention, 75015 Paris, France", "Brasiloja": "16 rue Ganneron, 75018 Paris, France",
"L'Angle": "28 rue de Ponthieu, 75008 Paris, France", "Botak cafe": "1 rue Paul albert, 75018 Paris, France",
"La cantine de Zo\u00e9": "136 rue du Faubourg poissonni\u00e8re, 75010 Paris, France",
"Institut des Cultures d'Islam": "19-23 rue L\u00e9on, 75018 Paris, France", "Institut des Cultures d'Islam": "19-23 rue L\u00e9on, 75018 Paris, France",
"Chez Miamophile": "6 rue M\u00e9lingue, 75019 Paris, France",
"Canopy Caf\u00e9 associatif": "19 rue Pajol, 75018 Paris, France", "Canopy Caf\u00e9 associatif": "19 rue Pajol, 75018 Paris, France",
"L'Entracte": "place de l'opera, 75002 Paris, France", "Caf\u00e9 rallye tournelles": "11 Quai de la Tournelle, 75005 Paris, France",
"Le S\u00e9vign\u00e9": "15 rue du Parc Royal, 75003 Paris, France", "Petits Freres des Pauvres": "47 rue de Batignolles, 75017 Paris, France",
"Le Caf\u00e9 d'avant": "35 rue Claude Bernard, 75005 Paris, France", "Le caf\u00e9 Monde et M\u00e9dias": "Place de la R\u00e9publique, 75003 Paris, France",
"Le Lucernaire": "53 rue Notre-Dame des Champs, 75006 Paris, France", "L'entrep\u00f4t": "157 rue Bercy 75012 Paris, 75012 Paris, France",
"Le Brigadier": "12 rue Blanche, 75009 Paris, France", "Coffee Chope": "344Vrue Vaugirard, 75015 Paris, France",
"L'\u00e2ge d'or": "26 rue du Docteur Magnan, 75013 Paris, France",
"Caf\u00e9 Victor": "10 boulevard Victor, 75015 Paris, France",
"L'empreinte": "54, avenue Daumesnil, 75012 Paris, France",
"L'horizon": "93, rue de la Roquette, 75011 Paris, France",
"Waikiki": "10 rue d\"Ulm, 75005 Paris, France",
"Au pays de Vannes": "34 bis rue de Wattignies, 75012 Paris, France",
"Caf\u00e9 Varenne": "36 rue de Varenne, 75007 Paris, France",
"l'El\u00e9phant du nil": "125 Rue Saint-Antoine, 75004 Paris, France",
"Le Comptoir": "354 bis rue Vaugirard, 75015 Paris, France", "Le Comptoir": "354 bis rue Vaugirard, 75015 Paris, France",
"Le Parc Vaugirard": "358 rue de Vaugirard, 75015 Paris, France", "L'empreinte": "54, avenue Daumesnil, 75012 Paris, France",
"le Zango": "58 rue Daguerre, 75014 Paris, France", "Caf\u00e9 Victor": "10 boulevard Victor, 75015 Paris, France",
"Caf\u00e9 Varenne": "36 rue de Varenne, 75007 Paris, France",
"Le Brigadier": "12 rue Blanche, 75009 Paris, France",
"Melting Pot": "3 rue de Lagny, 75020 Paris, France", "Melting Pot": "3 rue de Lagny, 75020 Paris, France",
"Pari's Caf\u00e9": "174 avenue de Clichy, 75017 Paris, France"} "L'Entracte": "place de l'opera, 75002 Paris, France",
"le Zango": "58 rue Daguerre, 75014 Paris, France",
"Panem": "18 rue de Crussol, 75011 Paris, France",
"Waikiki": "10 rue d\"Ulm, 75005 Paris, France",
"l'El\u00e9phant du nil": "125 Rue Saint-Antoine, 75004 Paris, France",
"Le Parc Vaugirard": "358 rue de Vaugirard, 75015 Paris, France",
"Pari's Caf\u00e9": "174 avenue de Clichy, 75017 Paris, France",
"Brasserie le Morvan": "61 rue du ch\u00e2teau d'eau, 75010 Paris, France",
"Au pays de Vannes": "34 bis rue de Wattignies, 75012 Paris, France",
"Le Lucernaire": "53 rue Notre-Dame des Champs, 75006 Paris, France",
"L'Angle": "28 rue de Ponthieu, 75008 Paris, France",
"Le Caf\u00e9 d'avant": "35 rue Claude Bernard, 75005 Paris, France",
"Caf\u00e9 Dupont": "198 rue de la Convention, 75015 Paris, France",
"L'\u00e2ge d'or": "26 rue du Docteur Magnan, 75013 Paris, France",
"Le S\u00e9vign\u00e9": "15 rue du Parc Royal, 75003 Paris, France",
"L'horizon": "93, rue de la Roquette, 75011 Paris, France"}

View File

@ -1,182 +1,182 @@
Extérieur Quai, 5, rue d'Alsace, 75010 Paris, France
Le Sully, 6 Bd henri IV, 75004 Paris, France
O q de poule, 53 rue du ruisseau, 75018 Paris, France O q de poule, 53 rue du ruisseau, 75018 Paris, France
Le chantereine, 51 Rue Victoire, 75009 Paris, France
La Caravane, Rue de la Fontaine au Roi, 75011 Paris, France
Le Pas Sage, 1 Passage du Grand Cerf, 75002 Paris, France Le Pas Sage, 1 Passage du Grand Cerf, 75002 Paris, France
La Renaissance, 112 Rue Championnet, 75018 Paris, France La Renaissance, 112 Rue Championnet, 75018 Paris, France
Le chantereine, 51 Rue Victoire, 75009 Paris, France Extérieur Quai, 5, rue d'Alsace, 75010 Paris, France
Le Müller, 11 rue Feutrier, 75018 Paris, France Le Reynou, 2 bis quai de la mégisserie, 75001 Paris, France
les montparnos, 65 boulevard Pasteur, 75015 Paris, France
Le bistrot de Maëlle et Augustin, 42 rue coquillère, 75001 Paris, France
Dédé la frite, 52 rue Notre-Dame des Victoires, 75002 Paris, France
Assaporare Dix sur Dix, 75, avenue Ledru-Rollin, 75012 Paris, France
Cardinal Saint-Germain, 11 boulevard Saint-Germain, 75005 Paris, France
Café antoine, 17 rue Jean de la Fontaine, 75016 Paris, France
Au cerceau d'or, 129 boulevard sebastopol, 75002 Paris, France
Le Chaumontois, 12 rue Armand Carrel, 75018 Paris, France
Aux cadrans, 21 ter boulevard Diderot, 75012 Paris, France
Le Saint Jean, 23 rue des abbesses, 75018 Paris, France
Le Square, 31 rue Saint-Dominique, 75007 Paris, France
Les Arcades, 61 rue de Ponthieu, 75008 Paris, France
Café Lea, 5 rue Claude Bernard, 75005 Paris, France
Le Bellerive, 71 quai de Seine, 75019 Paris, France
La Bauloise, 36 rue du hameau, 75015 Paris, France
Le Dellac, 14 rue Rougemont, 75009 Paris, France
Le Bosquet, 46 avenue Bosquet, 75007 Paris, France
Le Sully, 6 Bd henri IV, 75004 Paris, France
Le Felteu, 1 rue Pecquay, 75004 Paris, France
Le drapeau de la fidelité, 21 rue Copreaux, 75015 Paris, France Le drapeau de la fidelité, 21 rue Copreaux, 75015 Paris, France
Le café des amis, 125 rue Blomet, 75015 Paris, France Le café des amis, 125 rue Blomet, 75015 Paris, France
Le Café Livres, 10 rue Saint Martin, 75004 Paris, France
Le Bosquet, 46 avenue Bosquet, 75007 Paris, France
Le Brio, 216, rue Marcadet, 75018 Paris, France
Le Kleemend's, 34 avenue Pierre Mendès-France, 75013 Paris, France Le Kleemend's, 34 avenue Pierre Mendès-France, 75013 Paris, France
Café Pierre, 202 rue du faubourg st antoine, 75012 Paris, France Café Pierre, 202 rue du faubourg st antoine, 75012 Paris, France
Les Arcades, 61 rue de Ponthieu, 75008 Paris, France Le Müller, 11 rue Feutrier, 75018 Paris, France
Le Square, 31 rue Saint-Dominique, 75007 Paris, France Le Café Livres, 10 rue Saint Martin, 75004 Paris, France
Assaporare Dix sur Dix, 75, avenue Ledru-Rollin, 75012 Paris, France
Au cerceau d'or, 129 boulevard sebastopol, 75002 Paris, France
Aux cadrans, 21 ter boulevard Diderot, 75012 Paris, France
Café antoine, 17 rue Jean de la Fontaine, 75016 Paris, France
Café Lea, 5 rue Claude Bernard, 75005 Paris, France
Cardinal Saint-Germain, 11 boulevard Saint-Germain, 75005 Paris, France
Dédé la frite, 52 rue Notre-Dame des Victoires, 75002 Paris, France
La Bauloise, 36 rue du hameau, 75015 Paris, France
Le Bellerive, 71 quai de Seine, 75019 Paris, France
Le bistrot de Maëlle et Augustin, 42 rue coquillère, 75001 Paris, France
Le Dellac, 14 rue Rougemont, 75009 Paris, France
Le Felteu, 1 rue Pecquay, 75004 Paris, France
Le Reynou, 2 bis quai de la mégisserie, 75001 Paris, France
Le Saint Jean, 23 rue des abbesses, 75018 Paris, France
les montparnos, 65 boulevard Pasteur, 75015 Paris, France
Drole d'endroit pour une rencontre, 58 rue de Montorgueil, 75002 Paris, France
Le pari's café, 104 rue caulaincourt, 75018 Paris, France
Le Poulailler, 60 rue saint-sabin, 75011 Paris, France
L'Assassin, 99 rue Jean-Pierre Timbaud, 75011 Paris, France
l'Usine, 1 rue d'Avron, 75020 Paris, France
La Bricole, 52 rue Liebniz, 75018 Paris, France
le ronsard, place maubert, 75005 Paris, France
Face Bar, 82 rue des archives, 75003 Paris, France
American Kitchen, 49 rue bichat, 75010 Paris, France
La Marine, 55 bis quai de valmy, 75010 Paris, France
Le Bloc, 21 avenue Brochant, 75017 Paris, France
La Recoleta au Manoir, 229 avenue Gambetta, 75020 Paris, France
Le Pareloup, 80 Rue Saint-Charles, 75015 Paris, France
La Brasserie Gaité, 3 rue de la Gaité, 75014 Paris, France
Café Zen, 46 rue Victoire, 75009 Paris, France
O'Breizh, 27 rue de Penthièvre, 75008 Paris, France
Le Petit Choiseul, 23 rue saint augustin, 75002 Paris, France
Invitez vous chez nous, 7 rue Epée de Bois, 75005 Paris, France
La Cordonnerie, 142 Rue Saint-Denis 75002 Paris, 75002 Paris, France La Cordonnerie, 142 Rue Saint-Denis 75002 Paris, 75002 Paris, France
Invitez vous chez nous, 7 rue Epée de Bois, 75005 Paris, France
Au bon coin, 49 rue des Cloys, 75018 Paris, France
La Brûlerie des Ternes, 111 rue mouffetard, 75005 Paris, France
Le Petit Choiseul, 23 rue saint augustin, 75002 Paris, France
O'Breizh, 27 rue de Penthièvre, 75008 Paris, France
Le Supercoin, 3, rue Baudelique, 75018 Paris, France Le Supercoin, 3, rue Baudelique, 75018 Paris, France
Populettes, 86 bis rue Riquet, 75018 Paris, France Populettes, 86 bis rue Riquet, 75018 Paris, France
Au bon coin, 49 rue des Cloys, 75018 Paris, France
Le Couvent, 69 rue Broca, 75013 Paris, France Le Couvent, 69 rue Broca, 75013 Paris, France
La Brûlerie des Ternes, 111 rue mouffetard, 75005 Paris, France Café Zen, 46 rue Victoire, 75009 Paris, France
L'Écir, 59 Boulevard Saint-Jacques, 75014 Paris, France
Le Chat bossu, 126, rue du Faubourg Saint Antoine, 75012 Paris, France Le Chat bossu, 126, rue du Faubourg Saint Antoine, 75012 Paris, France
Denfert café, 58 boulvevard Saint Jacques, 75014 Paris, France
Le Café frappé, 95 rue Montmartre, 75002 Paris, France
La Perle, 78 rue vieille du temple, 75003 Paris, France
Le Descartes, 1 rue Thouin, 75005 Paris, France
Bagels & Coffee Corner, Place de Clichy, 75017 Paris, France
Le petit club, 55 rue de la tombe Issoire, 75014 Paris, France Le petit club, 55 rue de la tombe Issoire, 75014 Paris, France
Le Plein soleil, 90 avenue Parmentier, 75011 Paris, France
Le Relais Haussmann, 146, boulevard Haussmann, 75008 Paris, France Le Relais Haussmann, 146, boulevard Haussmann, 75008 Paris, France
Le Malar, 88 rue Saint-Dominique, 75007 Paris, France Denfert café, 58 boulvevard Saint Jacques, 75014 Paris, France
Au panini de la place, 47 rue Belgrand, 75020 Paris, France Bagels & Coffee Corner, Place de Clichy, 75017 Paris, France
Le Village, 182 rue de Courcelles, 75017 Paris, France Le Plein soleil, 90 avenue Parmentier, 75011 Paris, France
Pause Café, 41 rue de Charonne, 75011 Paris, France La Perle, 78 rue vieille du temple, 75003 Paris, France
Le Pure café, 14 rue Jean Macé, 75011 Paris, France Le Café frappé, 95 rue Montmartre, 75002 Paris, France
Extra old café, 307 fg saint Antoine, 75011 Paris, France L'Écir, 59 Boulevard Saint-Jacques, 75014 Paris, France
Chez Fafa, 44 rue Vinaigriers, 75010 Paris, France Le Descartes, 1 rue Thouin, 75005 Paris, France
En attendant l'or, 3 rue Faidherbe, 75011 Paris, France
Brûlerie San José, 30 rue des Petits-Champs, 75002 Paris, France Brûlerie San José, 30 rue des Petits-Champs, 75002 Paris, France
Café de la Mairie (du VIII), rue de Lisbonne, 75008 Paris, France Café de la Mairie (du VIII), rue de Lisbonne, 75008 Paris, France
Au panini de la place, 47 rue Belgrand, 75020 Paris, France
Extra old café, 307 fg saint Antoine, 75011 Paris, France
En attendant l'or, 3 rue Faidherbe, 75011 Paris, France
Le Pure café, 14 rue Jean Macé, 75011 Paris, France
Le Village, 182 rue de Courcelles, 75017 Paris, France
Le Malar, 88 rue Saint-Dominique, 75007 Paris, France
Pause Café, 41 rue de Charonne, 75011 Paris, France
Chez Fafa, 44 rue Vinaigriers, 75010 Paris, France
La Recoleta au Manoir, 229 avenue Gambetta, 75020 Paris, France
Le Pareloup, 80 Rue Saint-Charles, 75015 Paris, France
La Marine, 55 bis quai de valmy, 75010 Paris, France
American Kitchen, 49 rue bichat, 75010 Paris, France
Face Bar, 82 rue des archives, 75003 Paris, France
Le Bloc, 21 avenue Brochant, 75017 Paris, France
La Bricole, 52 rue Liebniz, 75018 Paris, France
le ronsard, place maubert, 75005 Paris, France
l'Usine, 1 rue d'Avron, 75020 Paris, France
La Brasserie Gaité, 3 rue de la Gaité, 75014 Paris, France
Le General Beuret, 9 Place du General Beuret, 75015 Paris, France
Le Cap Bourbon, 1 rue Louis le Grand, 75002 Paris, France
Le Ragueneau, 202 rue Saint-Honoré, 75001 Paris, France
Le Germinal, 95 avenue Emile Zola, 75015 Paris, France
Café Martin, 2 place Martin Nadaud, 75001 Paris, France Café Martin, 2 place Martin Nadaud, 75001 Paris, France
Etienne, 14 rue Turbigo, Paris, 75001 Paris, France Etienne, 14 rue Turbigo, Paris, 75001 Paris, France
L'ingénu, 184 bd Voltaire, 75011 Paris, France L'ingénu, 184 bd Voltaire, 75011 Paris, France
L'Olive, 8 rue L'Olive, 75018 Paris, France
Le Biz, 18 rue Favart, 75002 Paris, France
Le Cap Bourbon, 1 rue Louis le Grand, 75002 Paris, France
Le General Beuret, 9 Place du General Beuret, 75015 Paris, France
Le Germinal, 95 avenue Emile Zola, 75015 Paris, France
Le Ragueneau, 202 rue Saint-Honoré, 75001 Paris, France
Le refuge, 72 rue lamarck, 75018 Paris, France Le refuge, 72 rue lamarck, 75018 Paris, France
Le Biz, 18 rue Favart, 75002 Paris, France
L'Olive, 8 rue L'Olive, 75018 Paris, France
Le sully, 13 rue du Faubourg Saint Denis, 75010 Paris, France Le sully, 13 rue du Faubourg Saint Denis, 75010 Paris, France
L'antre d'eux, 16 rue DE MEZIERES, 75006 Paris, France Drole d'endroit pour une rencontre, 58 rue de Montorgueil, 75002 Paris, France
Le bal du pirate, 60 rue des bergers, 75015 Paris, France L'Assassin, 99 rue Jean-Pierre Timbaud, 75011 Paris, France
zic zinc, 95 rue claude decaen, 75012 Paris, France Le pari's café, 104 rue caulaincourt, 75018 Paris, France
l'orillon bar, 35 rue de l'orillon, 75011 Paris, France Le Poulailler, 60 rue saint-sabin, 75011 Paris, France
Le Zazabar, 116 Rue de Ménilmontant, 75020 Paris, France Chai 33, 33 Cour Saint Emilion, 75012 Paris, France
L'Inévitable, 22 rue Linné, 75005 Paris, France
Le Dunois, 77 rue Dunois, 75013 Paris, France
Ragueneau, 202 rue Saint Honoré, 75001 Paris, France
Le Caminito, 48 rue du Dessous des Berges, 75013 Paris, France
Epicerie Musicale, 55bis quai de Valmy, 75010 Paris, France
Le petit Bretonneau, Le petit Bretonneau - à l'intérieur de l'Hôpital, 75018 Paris, France
Le Centenaire, 104 rue amelot, 75011 Paris, France
La Montagne Sans Geneviève, 13 Rue du Pot de Fer, 75005 Paris, France
Les Pères Populaires, 46 rue de Buzenval, 75020 Paris, France
Cafe de grenelle, 188 rue de Grenelle, 75007 Paris, France
Le relais de la victoire, 73 rue de la Victoire, 75009 Paris, France
La chaumière gourmande, Route de la Muette à Neuilly
Club hippique du Jardin dAcclimatation, 75016 Paris, France
Le Chaumontois, 12 rue Armand Carrel, 75018 Paris, France
Caves populaires, 22 rue des Dames, 75017 Paris, France
Caprice café, 12 avenue Jean Moulin, 75014 Paris, France
Tamm Bara, 7 rue Clisson, 75013 Paris, France
L'anjou, 1 rue de Montholon, 75009 Paris, France
Café dans l'aerogare Air France Invalides, 2 rue Robert Esnault Pelterie, 75007 Paris, France Café dans l'aerogare Air France Invalides, 2 rue Robert Esnault Pelterie, 75007 Paris, France
Chez Prune, 36 rue Beaurepaire, 75010 Paris, France Les Pères Populaires, 46 rue de Buzenval, 75020 Paris, France
Au Vin Des Rues, 21 rue Boulard, 75014 Paris, France Epicerie Musicale, 55bis quai de Valmy, 75010 Paris, France
bistrot les timbrés, 14 rue d'alleray, 75015 Paris, France Le relais de la victoire, 73 rue de la Victoire, 75009 Paris, France
Café beauveau, 9 rue de Miromesnil, 75008 Paris, France Le Centenaire, 104 rue amelot, 75011 Paris, France
Café Pistache, 9 rue des petits champs, 75001 Paris, France Café Pistache, 9 rue des petits champs, 75001 Paris, France
La Cagnotte, 13 Rue Jean-Baptiste Dumay, 75020 Paris, France La Cagnotte, 13 Rue Jean-Baptiste Dumay, 75020 Paris, France
le 1 cinq, 172 rue de vaugirard, 75015 Paris, France Le bal du pirate, 60 rue des bergers, 75015 Paris, France
bistrot les timbrés, 14 rue d'alleray, 75015 Paris, France
Le Killy Jen, 28 bis boulevard Diderot, 75012 Paris, France Le Killy Jen, 28 bis boulevard Diderot, 75012 Paris, France
le 1 cinq, 172 rue de vaugirard, 75015 Paris, France
Les Artisans, 106 rue Lecourbe, 75015 Paris, France Les Artisans, 106 rue Lecourbe, 75015 Paris, France
Peperoni, 83 avenue de Wagram, 75001 Paris, France Peperoni, 83 avenue de Wagram, 75001 Paris, France
le lutece, 380 rue de vaugirard, 75015 Paris, France Ragueneau, 202 rue Saint Honoré, 75001 Paris, France
Brasiloja, 16 rue Ganneron, 75018 Paris, France l'orillon bar, 35 rue de l'orillon, 75011 Paris, France
Rivolux, 16 rue de Rivoli, 75004 Paris, France zic zinc, 95 rue claude decaen, 75012 Paris, France
Chai 33, 33 Cour Saint Emilion, 75012 Paris, France L'Inévitable, 22 rue Linné, 75005 Paris, France
L'européen, 21 Bis Boulevard Diderot, 75012 Paris, France Le Brio, 216, rue Marcadet, 75018 Paris, France
NoMa, 39 rue Notre Dame de Nazareth, 75003 Paris, France Le Dunois, 77 rue Dunois, 75013 Paris, France
O'Paris, 1 Rue des Envierges, 75020 Paris, France La Montagne Sans Geneviève, 13 Rue du Pot de Fer, 75005 Paris, France
Café Clochette, 16 avenue Richerand, 75010 Paris, France Le Caminito, 48 rue du Dessous des Berges, 75013 Paris, France
Le petit Bretonneau, Le petit Bretonneau - à l'intérieur de l'Hôpital, 75018 Paris, France
La chaumière gourmande, Route de la Muette à Neuilly
Club hippique du Jardin dAcclimatation, 75016 Paris, France
Caprice café, 12 avenue Jean Moulin, 75014 Paris, France
Le Zazabar, 116 Rue de Ménilmontant, 75020 Paris, France
Café beauveau, 9 rue de Miromesnil, 75008 Paris, France
Caves populaires, 22 rue des Dames, 75017 Paris, France
Cafe de grenelle, 188 rue de Grenelle, 75007 Paris, France
Au Vin Des Rues, 21 rue Boulard, 75014 Paris, France
L'antre d'eux, 16 rue DE MEZIERES, 75006 Paris, France
Chez Prune, 36 rue Beaurepaire, 75010 Paris, France
L'anjou, 1 rue de Montholon, 75009 Paris, France
Tamm Bara, 7 rue Clisson, 75013 Paris, France
La cantoche de Paname, 40 Boulevard Beaumarchais, 75011 Paris, France La cantoche de Paname, 40 Boulevard Beaumarchais, 75011 Paris, France
Le Saint René, 148 Boulevard de Charonne, 75020 Paris, France Le Saint René, 148 Boulevard de Charonne, 75020 Paris, France
Café Clochette, 16 avenue Richerand, 75010 Paris, France
L'européen, 21 Bis Boulevard Diderot, 75012 Paris, France
Le BB (Bouchon des Batignolles), 2 rue Lemercier, 75017 Paris, France
La Liberté, 196 rue du faubourg saint-antoine, 75012 Paris, France La Liberté, 196 rue du faubourg saint-antoine, 75012 Paris, France
Chez Rutabaga, 16 rue des Petits Champs, 75002 Paris, France Chez Rutabaga, 16 rue des Petits Champs, 75002 Paris, France
Le BB (Bouchon des Batignolles), 2 rue Lemercier, 75017 Paris, France
La Brocante, 10 rue Rossini, 75009 Paris, France
Le Plomb du cantal, 3 rue Gaîté, 75014 Paris, France
Les caves populaires, 22 rue des Dames, 75017 Paris, France Les caves populaires, 22 rue des Dames, 75017 Paris, France
Chez Luna, 108 rue de Ménilmontant, 75020 Paris, France Le Plomb du cantal, 3 rue Gaîté, 75014 Paris, France
Le bar Fleuri, 1 rue du Plateau, 75019 Paris, France
Trois pièces cuisine, 101 rue des dames, 75017 Paris, France
Le Zinc, 61 avenue de la Motte Picquet, 75015 Paris, France
La cantine de Zoé, 136 rue du Faubourg poissonnière, 75010 Paris, France
Les Vendangeurs, 6/8 rue Stanislas, 75006 Paris, France
L'avant comptoir, 3 carrefour de l'Odéon, 75006 Paris, France
Botak cafe, 1 rue Paul albert, 75018 Paris, France
le chateau d'eau, 67 rue du Château d'eau, 75010 Paris, France
Bistrot Saint-Antoine, 58 rue du Fbg Saint-Antoine, 75012 Paris, France Bistrot Saint-Antoine, 58 rue du Fbg Saint-Antoine, 75012 Paris, France
Trois pièces cuisine, 101 rue des dames, 75017 Paris, France
La Brocante, 10 rue Rossini, 75009 Paris, France
Le Zinc, 61 avenue de la Motte Picquet, 75015 Paris, France
Chez Oscar, 11/13 boulevard Beaumarchais, 75004 Paris, France Chez Oscar, 11/13 boulevard Beaumarchais, 75004 Paris, France
Le Fronton, 63 rue de Ponthieu, 75008 Paris, France
Le Piquet, 48 avenue de la Motte Picquet, 75015 Paris, France Le Piquet, 48 avenue de la Motte Picquet, 75015 Paris, France
Le Tournebride, 104 rue Mouffetard, 75005 Paris, France L'avant comptoir, 3 carrefour de l'Odéon, 75006 Paris, France
le chateau d'eau, 67 rue du Château d'eau, 75010 Paris, France
maison du vin, 52 rue des plantes, 75014 Paris, France maison du vin, 52 rue des plantes, 75014 Paris, France
Coffee Chope, 344Vrue Vaugirard, 75015 Paris, France Les Vendangeurs, 6/8 rue Stanislas, 75006 Paris, France
L'entrepôt, 157 rue Bercy 75012 Paris, 75012 Paris, France NoMa, 39 rue Notre Dame de Nazareth, 75003 Paris, France
Le café Monde et Médias, Place de la République, 75003 Paris, France Chez Luna, 108 rue de Ménilmontant, 75020 Paris, France
Café rallye tournelles, 11 Quai de la Tournelle, 75005 Paris, France Le Tournebride, 104 rue Mouffetard, 75005 Paris, France
Brasserie le Morvan, 61 rue du château d'eau, 75010 Paris, France le lutece, 380 rue de vaugirard, 75015 Paris, France
Chez Miamophile, 6 rue Mélingue, 75019 Paris, France Le bar Fleuri, 1 rue du Plateau, 75019 Paris, France
La Caravane, Rue de la Fontaine au Roi, 75011 Paris, France Le Fronton, 63 rue de Ponthieu, 75008 Paris, France
Panem, 18 rue de Crussol, 75011 Paris, France O'Paris, 1 Rue des Envierges, 75020 Paris, France
Petits Freres des Pauvres, 47 rue de Batignolles, 75017 Paris, France Rivolux, 16 rue de Rivoli, 75004 Paris, France
Café Dupont, 198 rue de la Convention, 75015 Paris, France Brasiloja, 16 rue Ganneron, 75018 Paris, France
L'Angle, 28 rue de Ponthieu, 75008 Paris, France Botak cafe, 1 rue Paul albert, 75018 Paris, France
La cantine de Zoé, 136 rue du Faubourg poissonnière, 75010 Paris, France
Institut des Cultures d'Islam, 19-23 rue Léon, 75018 Paris, France Institut des Cultures d'Islam, 19-23 rue Léon, 75018 Paris, France
Chez Miamophile, 6 rue Mélingue, 75019 Paris, France
Canopy Café associatif, 19 rue Pajol, 75018 Paris, France Canopy Café associatif, 19 rue Pajol, 75018 Paris, France
L'Entracte, place de l'opera, 75002 Paris, France Café rallye tournelles, 11 Quai de la Tournelle, 75005 Paris, France
Le Sévigné, 15 rue du Parc Royal, 75003 Paris, France Petits Freres des Pauvres, 47 rue de Batignolles, 75017 Paris, France
Le Café d'avant, 35 rue Claude Bernard, 75005 Paris, France Le café Monde et Médias, Place de la République, 75003 Paris, France
Le Lucernaire, 53 rue Notre-Dame des Champs, 75006 Paris, France L'entrepôt, 157 rue Bercy 75012 Paris, 75012 Paris, France
Le Brigadier, 12 rue Blanche, 75009 Paris, France Coffee Chope, 344Vrue Vaugirard, 75015 Paris, France
L'âge d'or, 26 rue du Docteur Magnan, 75013 Paris, France
Café Victor, 10 boulevard Victor, 75015 Paris, France
L'empreinte, 54, avenue Daumesnil, 75012 Paris, France
L'horizon, 93, rue de la Roquette, 75011 Paris, France
Waikiki, 10 rue d"Ulm, 75005 Paris, France
Au pays de Vannes, 34 bis rue de Wattignies, 75012 Paris, France
Café Varenne, 36 rue de Varenne, 75007 Paris, France
l'Eléphant du nil, 125 Rue Saint-Antoine, 75004 Paris, France
Le Comptoir, 354 bis rue Vaugirard, 75015 Paris, France Le Comptoir, 354 bis rue Vaugirard, 75015 Paris, France
Le Parc Vaugirard, 358 rue de Vaugirard, 75015 Paris, France L'empreinte, 54, avenue Daumesnil, 75012 Paris, France
le Zango, 58 rue Daguerre, 75014 Paris, France Café Victor, 10 boulevard Victor, 75015 Paris, France
Café Varenne, 36 rue de Varenne, 75007 Paris, France
Le Brigadier, 12 rue Blanche, 75009 Paris, France
Melting Pot, 3 rue de Lagny, 75020 Paris, France Melting Pot, 3 rue de Lagny, 75020 Paris, France
L'Entracte, place de l'opera, 75002 Paris, France
le Zango, 58 rue Daguerre, 75014 Paris, France
Panem, 18 rue de Crussol, 75011 Paris, France
Waikiki, 10 rue d"Ulm, 75005 Paris, France
l'Eléphant du nil, 125 Rue Saint-Antoine, 75004 Paris, France
Le Parc Vaugirard, 358 rue de Vaugirard, 75015 Paris, France
Pari's Café, 174 avenue de Clichy, 75017 Paris, France Pari's Café, 174 avenue de Clichy, 75017 Paris, France
Brasserie le Morvan, 61 rue du château d'eau, 75010 Paris, France
Au pays de Vannes, 34 bis rue de Wattignies, 75012 Paris, France
Le Lucernaire, 53 rue Notre-Dame des Champs, 75006 Paris, France
L'Angle, 28 rue de Ponthieu, 75008 Paris, France
Le Café d'avant, 35 rue Claude Bernard, 75005 Paris, France
Café Dupont, 198 rue de la Convention, 75015 Paris, France
L'âge d'or, 26 rue du Docteur Magnan, 75013 Paris, France
Le Sévigné, 15 rue du Parc Royal, 75003 Paris, France
L'horizon, 93, rue de la Roquette, 75011 Paris, France

View File

@ -0,0 +1,21 @@
import bonobo
from bonobo import Filter
class OddOnlyFilter(Filter):
def filter(self, i):
return i % 2
@Filter
def MultiplesOfThreeOnlyFilter(self, i):
return not (i % 3)
graph = bonobo.Graph(
lambda: tuple(range(50)),
OddOnlyFilter(),
MultiplesOfThreeOnlyFilter(),
print,
)

View File

@ -1,21 +1,27 @@
import traceback import traceback
from contextlib import contextmanager
from time import sleep from time import sleep
from bonobo.config import Container from bonobo.config import Container
from bonobo.config.processors import resolve_processors, ContextCurrifier from bonobo.config.processors import ContextCurrifier
from bonobo.plugins import get_enhancers
from bonobo.util.errors import print_error from bonobo.util.errors import print_error
from bonobo.util.iterators import ensure_tuple from bonobo.util.objects import Wrapper, get_name
from bonobo.util.objects import Wrapper
@contextmanager
def unrecoverable(error_handler):
try:
yield
except Exception as exc: # pylint: disable=broad-except
error_handler(exc, traceback.format_exc())
raise # raise unrecoverableerror from x ?
class LoopingExecutionContext(Wrapper): class LoopingExecutionContext(Wrapper):
alive = True alive = True
PERIOD = 0.25 PERIOD = 0.25
@property
def state(self):
return self._started, self._stopped
@property @property
def started(self): def started(self):
return self._started return self._started
@ -26,7 +32,9 @@ class LoopingExecutionContext(Wrapper):
def __init__(self, wrapped, parent, services=None): def __init__(self, wrapped, parent, services=None):
super().__init__(wrapped) super().__init__(wrapped)
self.parent = parent self.parent = parent
if services: if services:
if parent: if parent:
raise RuntimeError( raise RuntimeError(
@ -36,19 +44,25 @@ class LoopingExecutionContext(Wrapper):
else: else:
self.services = None self.services = None
self._started, self._stopped, self._stack = False, False, None self._started, self._stopped = False, False
self._stack = None
# XXX enhancers
self._enhancers = get_enhancers(self.wrapped)
def start(self): def start(self):
assert self.state == (False, if self.started:
False), ('{}.start() can only be called on a new node.').format(type(self).__name__) raise RuntimeError('Cannot start a node twice ({}).'.format(get_name(self)))
self._started = True self._started = True
self._stack = ContextCurrifier(self.wrapped, *self._get_initial_context()) self._stack = ContextCurrifier(self.wrapped, *self._get_initial_context())
try: with unrecoverable(self.handle_error):
self._stack.setup(self) self._stack.setup(self)
except Exception as exc: # pylint: disable=broad-except
self.handle_error(exc, traceback.format_exc()) for enhancer in self._enhancers:
raise with unrecoverable(self.handle_error):
enhancer.start(self)
def loop(self): def loop(self):
"""Generic loop. A bit boring. """ """Generic loop. A bit boring. """
@ -61,16 +75,17 @@ class LoopingExecutionContext(Wrapper):
raise NotImplementedError('Abstract.') raise NotImplementedError('Abstract.')
def stop(self): def stop(self):
assert self._started, ('{}.stop() can only be called on a previously started node.').format(type(self).__name__) if not self.started:
raise RuntimeError('Cannot stop an unstarted node ({}).'.format(get_name(self)))
if self._stopped: if self._stopped:
return return
self._stopped = True
try: try:
with unrecoverable(self.handle_error):
self._stack.teardown() self._stack.teardown()
except Exception as exc: # pylint: disable=broad-except finally:
self.handle_error(exc, traceback.format_exc()) self._stopped = True
raise
def handle_error(self, exc, trace): def handle_error(self, exc, trace):
return print_error(exc, trace, context=self.wrapped) return print_error(exc, trace, context=self.wrapped)

View File

@ -21,16 +21,12 @@ class GraphExecutionContext:
def __init__(self, graph, plugins=None, services=None): def __init__(self, graph, plugins=None, services=None):
self.graph = graph self.graph = graph
self.nodes = [NodeExecutionContext(node, parent=self) for node in self.graph.nodes] self.nodes = [NodeExecutionContext(node, parent=self) for node in self.graph]
self.plugins = [PluginExecutionContext(plugin, parent=self) for plugin in plugins or ()] self.plugins = [PluginExecutionContext(plugin, parent=self) for plugin in plugins or ()]
self.services = Container(services) if services else Container() self.services = Container(services) if services else Container()
for i, node_context in enumerate(self): for i, node_context in enumerate(self):
try:
node_context.outputs = [self[j].input for j in self.graph.outputs_of(i)] node_context.outputs = [self[j].input for j in self.graph.outputs_of(i)]
except KeyError:
continue
node_context.input.on_begin = partial(node_context.send, BEGIN, _control=True) node_context.input.on_begin = partial(node_context.send, BEGIN, _control=True)
node_context.input.on_end = partial(node_context.send, END, _control=True) node_context.input.on_end = partial(node_context.send, END, _control=True)
node_context.input.on_finalize = partial(node_context.stop) node_context.input.on_finalize = partial(node_context.stop)
@ -50,7 +46,7 @@ class GraphExecutionContext:
for i in self.graph.outputs_of(BEGIN): for i in self.graph.outputs_of(BEGIN):
for message in messages: for message in messages:
self[i].recv(message) self[i].write(message)
def start(self): def start(self):
# todo use strategy # todo use strategy

View File

@ -3,13 +3,15 @@ from queue import Empty
from time import sleep from time import sleep
from bonobo.constants import INHERIT_INPUT, NOT_MODIFIED from bonobo.constants import INHERIT_INPUT, NOT_MODIFIED
from bonobo.core.inputs import Input
from bonobo.core.statistics import WithStatistics
from bonobo.errors import InactiveReadableError from bonobo.errors import InactiveReadableError
from bonobo.execution.base import LoopingExecutionContext from bonobo.execution.base import LoopingExecutionContext
from bonobo.structs.bags import Bag from bonobo.structs.bags import Bag
from bonobo.structs.inputs import Input
from bonobo.util.compat import deprecated_alias
from bonobo.util.errors import is_error from bonobo.util.errors import is_error
from bonobo.util.iterators import iter_if_not_sequence from bonobo.util.iterators import iter_if_not_sequence
from bonobo.util.objects import get_name
from bonobo.util.statistics import WithStatistics
class NodeExecutionContext(WithStatistics, LoopingExecutionContext): class NodeExecutionContext(WithStatistics, LoopingExecutionContext):
@ -20,7 +22,11 @@ class NodeExecutionContext(WithStatistics, LoopingExecutionContext):
@property @property
def alive(self): def alive(self):
"""todo check if this is right, and where it is used""" """todo check if this is right, and where it is used"""
return self.input.alive and self._started and not self._stopped return self._started and not self._stopped
@property
def alive_str(self):
return '+' if self.alive else '-'
def __init__(self, wrapped, parent=None, services=None): def __init__(self, wrapped, parent=None, services=None):
LoopingExecutionContext.__init__(self, wrapped, parent=parent, services=services) LoopingExecutionContext.__init__(self, wrapped, parent=parent, services=services)
@ -30,18 +36,13 @@ class NodeExecutionContext(WithStatistics, LoopingExecutionContext):
self.outputs = [] self.outputs = []
def __str__(self): def __str__(self):
return (('+' if self.alive else '-') + ' ' + self.__name__ + ' ' + self.get_statistics_as_string()).strip() return self.alive_str + ' ' + self.__name__ + self.get_statistics_as_string(prefix=' ')
def __repr__(self): def __repr__(self):
stats = self.get_statistics_as_string().strip() name, type_name = get_name(self), get_name(type(self))
return '<{}({}{}){}>'.format( return '<{}({}{}){}>'.format(type_name, self.alive_str, name, self.get_statistics_as_string(prefix=' '))
type(self).__name__,
'+' if self.alive else '',
self.__name__,
(' ' + stats) if stats else '',
)
def recv(self, *messages): def write(self, *messages):
""" """
Push a message list to this context's input queue. Push a message list to this context's input queue.
@ -50,6 +51,9 @@ class NodeExecutionContext(WithStatistics, LoopingExecutionContext):
for message in messages: for message in messages:
self.input.put(message) self.input.put(message)
# XXX deprecated alias
recv = deprecated_alias('recv', write)
def send(self, value, _control=False): def send(self, value, _control=False):
""" """
Sends a message to all of this context's outputs. Sends a message to all of this context's outputs.
@ -57,12 +61,19 @@ class NodeExecutionContext(WithStatistics, LoopingExecutionContext):
:param mixed value: message :param mixed value: message
:param _control: if true, won't count in statistics. :param _control: if true, won't count in statistics.
""" """
if not _control: if not _control:
self.increment('out') self.increment('out')
if is_error(value):
value.apply(self.handle_error)
else:
for output in self.outputs: for output in self.outputs:
output.put(value) output.put(value)
def get(self): push = deprecated_alias('push', send)
def get(self): # recv() ? input_data = self.receive()
""" """
Get from the queue first, then increment stats, so if Queue raise Timeout or Empty, stat won't be changed. Get from the queue first, then increment stats, so if Queue raise Timeout or Empty, stat won't be changed.
@ -95,12 +106,6 @@ class NodeExecutionContext(WithStatistics, LoopingExecutionContext):
# todo add timer # todo add timer
self.handle_results(input_bag, input_bag.apply(self._stack)) self.handle_results(input_bag, input_bag.apply(self._stack))
def push(self, bag):
# MAKE THIS PUBLIC API FOR CONTEXT PROCESSORS !!!
# xxx handle error or send in first call to apply(...)?
# xxx return value ?
bag.apply(self.handle_error) if is_error(bag) else self.send(bag)
def handle_results(self, input_bag, results): def handle_results(self, input_bag, results):
# self._exec_time += timer.duration # self._exec_time += timer.duration
# Put data onto output channels # Put data onto output channels
@ -108,7 +113,7 @@ class NodeExecutionContext(WithStatistics, LoopingExecutionContext):
results = iter_if_not_sequence(results) results = iter_if_not_sequence(results)
except TypeError: # not an iterator except TypeError: # not an iterator
if results: if results:
self.push(_resolve(input_bag, results)) self.send(_resolve(input_bag, results))
else: else:
# case with no result, an execution went through anyway, use for stats. # case with no result, an execution went through anyway, use for stats.
# self._exec_count += 1 # self._exec_count += 1
@ -120,7 +125,7 @@ class NodeExecutionContext(WithStatistics, LoopingExecutionContext):
except StopIteration: except StopIteration:
break break
else: else:
self.push(_resolve(input_bag, result)) self.send(_resolve(input_bag, result))
def _resolve(input_bag, output): def _resolve(input_bag, output):

View File

@ -1,40 +1,13 @@
# -*- coding: utf-8 -*-
#
# Copyright 2012-2017 Romain Dorgueil
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import functools import functools
import sys import sys
from colorama import Fore, Style from colorama import Style, Fore
from bonobo import settings
from bonobo.plugins import Plugin from bonobo.plugins import Plugin
from bonobo.util.term import CLEAR_EOL, MOVE_CURSOR_UP from bonobo.util.term import CLEAR_EOL, MOVE_CURSOR_UP
@functools.lru_cache(1)
def memory_usage():
import os, psutil
process = psutil.Process(os.getpid())
return process.get_memory_info()[0] / float(2**20)
# @lru_cache(64)
# def execution_time(harness):
# return datetime.datetime.now() - harness._started_at
class ConsoleOutputPlugin(Plugin): class ConsoleOutputPlugin(Plugin):
""" """
Outputs status information to the connected stdout. Can be a TTY, with or without support for colors/cursor Outputs status information to the connected stdout. Can be a TTY, with or without support for colors/cursor
@ -50,15 +23,14 @@ class ConsoleOutputPlugin(Plugin):
self.prefix = '' self.prefix = ''
def _write(self, graph_context, rewind): def _write(self, graph_context, rewind):
profile, debug = False, False if settings.PROFILE:
if profile:
append = ( append = (
('Memory', '{0:.2f} Mb'.format(memory_usage())), ('Memory', '{0:.2f} Mb'.format(memory_usage())),
# ('Total time', '{0} s'.format(execution_time(harness))), # ('Total time', '{0} s'.format(execution_time(harness))),
) )
else: else:
append = () append = ()
self.write(graph_context, prefix=self.prefix, append=append, debug=debug, profile=profile, rewind=rewind) self.write(graph_context, prefix=self.prefix, append=append, rewind=rewind)
def run(self): def run(self):
if sys.stdout.isatty(): if sys.stdout.isatty():
@ -70,23 +42,24 @@ class ConsoleOutputPlugin(Plugin):
self._write(self.context.parent, rewind=False) self._write(self.context.parent, rewind=False)
@staticmethod @staticmethod
def write(context, prefix='', rewind=True, append=None, debug=False, profile=False): def write(context, prefix='', rewind=True, append=None):
t_cnt = len(context) t_cnt = len(context)
for i, component in enumerate(context): for i in context.graph.topologically_sorted_indexes:
if component.alive: node = context[i]
name_suffix = '({})'.format(i) if settings.DEBUG else ''
if node.alive:
_line = ''.join( _line = ''.join(
( (
Fore.BLACK, '({})'.format(i + 1), Style.RESET_ALL, ' ', Style.BRIGHT, '+', Style.RESET_ALL, ' ', ' ', Style.BRIGHT, '+', Style.RESET_ALL, ' ', node.name, name_suffix, ' ',
component.name, ' ', component.get_statistics_as_string(debug=debug, node.get_statistics_as_string(), Style.RESET_ALL, ' ',
profile=profile), Style.RESET_ALL, ' ',
) )
) )
else: else:
_line = ''.join( _line = ''.join(
( (
Fore.BLACK, '({})'.format(i + 1), ' - ', component.name, ' ', ' ', Fore.BLACK, '-', ' ', node.name, name_suffix, ' ', node.get_statistics_as_string(),
component.get_statistics_as_string(debug=debug, profile=profile), Style.RESET_ALL, ' ', Style.RESET_ALL, ' ',
) )
) )
print(prefix + _line + '\033[0K') print(prefix + _line + '\033[0K')
@ -106,3 +79,10 @@ class ConsoleOutputPlugin(Plugin):
if rewind: if rewind:
print(CLEAR_EOL) print(CLEAR_EOL)
print(MOVE_CURSOR_UP(t_cnt + 2)) print(MOVE_CURSOR_UP(t_cnt + 2))
@functools.lru_cache(1)
def memory_usage():
import os, psutil
process = psutil.Process(os.getpid())
return process.memory_info()[0] / float(2**20)

View File

@ -1,5 +0,0 @@
from .plugin import ConsoleOutputPlugin
__all__ = [
'ConsoleOutputPlugin',
]

View File

@ -3,9 +3,8 @@ from urllib.parse import urlencode
import requests # todo: make this a service so we can substitute it ? import requests # todo: make this a service so we can substitute it ?
from bonobo.config import Option from bonobo.config import Option
from bonobo.config.processors import ContextProcessor, contextual from bonobo.config.processors import ContextProcessor
from bonobo.config.configurables import Configurable from bonobo.config.configurables import Configurable
from bonobo.util.compat import deprecated
from bonobo.util.objects import ValueHolder from bonobo.util.objects import ValueHolder
@ -47,12 +46,7 @@ class OpenDataSoftAPI(Configurable):
for row in records: for row in records:
yield {**row.get('fields', {}), 'geometry': row.get('geometry', {})} yield {**row.get('fields', {}), 'geometry': row.get('geometry', {})}
start.value += self.rows start += self.rows
@deprecated
def from_opendatasoft_api(dataset, **kwargs):
return OpenDataSoftAPI(dataset=dataset, **kwargs)
__all__ = [ __all__ = [

9
bonobo/nodes/__init__.py Normal file
View File

@ -0,0 +1,9 @@
from bonobo.nodes.io import __all__ as _all_io
from bonobo.nodes.io import *
from bonobo.nodes.basics import __all__ as _all_basics
from bonobo.nodes.basics import *
from bonobo.nodes.filter import Filter
__all__ = _all_basics + _all_io + ['Filter']

View File

@ -3,10 +3,12 @@ from pprint import pprint as _pprint
from colorama import Fore, Style from colorama import Fore, Style
from bonobo.config import Configurable, Option
from bonobo.config.processors import ContextProcessor from bonobo.config.processors import ContextProcessor
from bonobo.structs.bags import Bag from bonobo.structs.bags import Bag
from bonobo.util.objects import ValueHolder from bonobo.util.objects import ValueHolder
from bonobo.util.term import CLEAR_EOL from bonobo.util.term import CLEAR_EOL
from bonobo.constants import NOT_MODIFIED
__all__ = [ __all__ = [
'identity', 'identity',
@ -23,19 +25,26 @@ def identity(x):
return x return x
def Limit(n=10): class Limit(Configurable):
from bonobo.constants import NOT_MODIFIED """
i = 0 Creates a Limit() node, that will only let go through the first n rows (defined by the `limit` option), unmodified.
def _limit(*args, **kwargs): .. attribute:: limit
nonlocal i, n
i += 1 Number of rows to let go through.
if i <= n:
"""
limit = Option(positional=True, default=10)
@ContextProcessor
def counter(self, context):
yield ValueHolder(0)
def call(self, counter, *args, **kwargs):
counter += 1
if counter <= self.limit:
yield NOT_MODIFIED yield NOT_MODIFIED
_limit.__name__ = 'Limit({})'.format(n)
return _limit
def Tee(f): def Tee(f):
from bonobo.constants import NOT_MODIFIED from bonobo.constants import NOT_MODIFIED
@ -57,7 +66,7 @@ def count(counter, *args, **kwargs):
def _count_counter(self, context): def _count_counter(self, context):
counter = ValueHolder(0) counter = ValueHolder(0)
yield counter yield counter
context.send(Bag(counter.value)) context.send(Bag(counter._value))
pprint = Tee(_pprint) pprint = Tee(_pprint)

26
bonobo/nodes/filter.py Normal file
View File

@ -0,0 +1,26 @@
from bonobo.constants import NOT_MODIFIED
from bonobo.config import Configurable, Method
class Filter(Configurable):
"""Filter out hashes from the stream depending on the :attr:`filter` callable return value, when called with the
current hash as parameter.
Can be used as a decorator on a filter callable.
.. attribute:: filter
A callable used to filter lines.
If the callable returns a true-ish value, the input will be passed unmodified to the next items.
Otherwise, it'll be burnt.
"""
filter = Method()
def call(self, *args, **kwargs):
if self.filter(*args, **kwargs):
return NOT_MODIFIED

View File

@ -1,7 +1,8 @@
import csv import csv
from bonobo.config import Option from bonobo.config import Option
from bonobo.config.processors import ContextProcessor, contextual from bonobo.config.processors import ContextProcessor
from bonobo.constants import NOT_MODIFIED
from bonobo.util.objects import ValueHolder from bonobo.util.objects import ValueHolder
from .file import FileHandler, FileReader, FileWriter from .file import FileHandler, FileReader, FileWriter
@ -45,8 +46,11 @@ class CsvReader(CsvHandler, FileReader):
def read(self, fs, file, headers): def read(self, fs, file, headers):
reader = csv.reader(file, delimiter=self.delimiter, quotechar=self.quotechar) reader = csv.reader(file, delimiter=self.delimiter, quotechar=self.quotechar)
headers.value = headers.value or next(reader)
field_count = len(headers.value) if not headers.get():
headers.set(next(reader))
field_count = len(headers)
if self.skip and self.skip > 0: if self.skip and self.skip > 0:
for _ in range(0, self.skip): for _ in range(0, self.skip):
@ -67,8 +71,9 @@ class CsvWriter(CsvHandler, FileWriter):
yield writer, headers yield writer, headers
def write(self, fs, file, lineno, writer, headers, row): def write(self, fs, file, lineno, writer, headers, row):
if not lineno.value: if not lineno:
headers.value = headers.value or row.keys() headers.set(headers.value or row.keys())
writer.writerow(headers.value) writer.writerow(headers.get())
writer.writerow(row[header] for header in headers.value) writer.writerow(row[header] for header in headers.get())
lineno.value += 1 lineno += 1
return NOT_MODIFIED

View File

@ -1,6 +1,7 @@
from bonobo.config import Option, Service from bonobo.config import Option, Service
from bonobo.config.configurables import Configurable from bonobo.config.configurables import Configurable
from bonobo.config.processors import ContextProcessor, contextual from bonobo.config.processors import ContextProcessor
from bonobo.constants import NOT_MODIFIED
from bonobo.util.objects import ValueHolder from bonobo.util.objects import ValueHolder
__all__ = [ __all__ = [
@ -85,7 +86,7 @@ class FileWriter(Writer):
@ContextProcessor @ContextProcessor
def lineno(self, context, fs, file): def lineno(self, context, fs, file):
lineno = ValueHolder(0, type=int) lineno = ValueHolder(0)
yield lineno yield lineno
def write(self, fs, file, lineno, row): def write(self, fs, file, lineno, row):
@ -93,7 +94,8 @@ class FileWriter(Writer):
Write a row on the next line of opened file in context. Write a row on the next line of opened file in context.
""" """
self._write_line(file, (self.eol if lineno.value else '') + row) self._write_line(file, (self.eol if lineno.value else '') + row)
lineno.value += 1 lineno += 1
return NOT_MODIFIED
def _write_line(self, file, line): def _write_line(self, file, line):
return file.write(line) return file.write(line)

View File

@ -1,6 +1,6 @@
import json import json
from bonobo.config.processors import ContextProcessor, contextual from bonobo.config.processors import ContextProcessor
from .file import FileWriter, FileReader from .file import FileWriter, FileReader
__all__ = [ __all__ = [

View File

@ -1,3 +1,7 @@
from bonobo.config import Configurable
from bonobo.util.objects import get_attribute_or_create
class Plugin: class Plugin:
""" """
A plugin is an extension to the core behavior of bonobo. If you're writing transformations, you should not need A plugin is an extension to the core behavior of bonobo. If you're writing transformations, you should not need
@ -21,3 +25,16 @@ class Plugin:
def finalize(self): def finalize(self):
pass pass
def get_enhancers(obj):
try:
return get_attribute_or_create(obj, '__enhancers__', list())
except AttributeError:
return list()
class NodeEnhancer(Configurable):
def __matmul__(self, other):
get_enhancers(other).append(self)
return other

16
bonobo/settings.py Normal file
View File

@ -0,0 +1,16 @@
import os
def to_bool(s):
if len(s):
if s.lower() in ('f', 'false', 'n', 'no', '0'):
return False
return True
return False
# Debug mode.
DEBUG = to_bool(os.environ.get('BONOBO_DEBUG', 'f'))
# Profile mode.
PROFILE = to_bool(os.environ.get('BONOBO_PROFILE', 'f'))

View File

@ -67,9 +67,7 @@ class Bag:
iter(func_or_iter) iter(func_or_iter)
def generator(): def generator():
nonlocal func_or_iter yield from func_or_iter
for x in func_or_iter:
yield x
return generator() return generator()
except TypeError as exc: except TypeError as exc:
@ -87,6 +85,9 @@ class Bag:
def inherit(cls, *args, **kwargs): def inherit(cls, *args, **kwargs):
return cls(*args, _flags=(INHERIT_INPUT, ), **kwargs) return cls(*args, _flags=(INHERIT_INPUT, ), **kwargs)
def __eq__(self, other):
return isinstance(other, Bag) and other.args == self.args and other.kwargs == self.kwargs
def __repr__(self): def __repr__(self):
return '<{} ({})>'.format( return '<{} ({})>'.format(
type(self).__name__, ', '. type(self).__name__, ', '.

View File

@ -3,29 +3,115 @@ from bonobo.constants import BEGIN
class Graph: class Graph:
""" """
Represents a coherent directed acyclic graph of components. Represents a directed graph of nodes.
""" """
def __init__(self, *chain): def __init__(self, *chain):
self.edges = {BEGIN: set()}
self.named = {}
self.nodes = [] self.nodes = []
self.graph = {BEGIN: set()}
self.add_chain(*chain) self.add_chain(*chain)
def __iter__(self):
yield from self.nodes
def __len__(self):
""" Node count.
"""
return len(self.nodes)
def __getitem__(self, key):
return self.nodes[key]
def outputs_of(self, idx, create=False): def outputs_of(self, idx, create=False):
if create and not idx in self.graph: """ Get a set of the outputs for a given node index.
self.graph[idx] = set() """
return self.graph[idx] if create and not idx in self.edges:
self.edges[idx] = set()
return self.edges[idx]
def add_node(self, c): def add_node(self, c):
i = len(self.nodes) """ Add a node without connections in this graph and returns its index.
"""
idx = len(self.nodes)
self.edges[idx] = set()
self.nodes.append(c) self.nodes.append(c)
return i return idx
def add_chain(self, *nodes, _input=BEGIN): def add_chain(self, *nodes, _input=BEGIN, _output=None, _name=None):
for node in nodes: """ Add a chain in this graph.
"""
if len(nodes):
_input = self._resolve_index(_input)
_output = self._resolve_index(_output)
for i, node in enumerate(nodes):
_next = self.add_node(node) _next = self.add_node(node)
if not i and _name:
if _name in self.named:
raise KeyError('Duplicate name {!r} in graph.'.format(_name))
self.named[_name] = _next
self.outputs_of(_input, create=True).add(_next) self.outputs_of(_input, create=True).add(_next)
_input = _next _input = _next
def __len__(self): if _output is not None:
return len(self.nodes) self.outputs_of(_input, create=True).add(_output)
if hasattr(self, '_topologcally_sorted_indexes_cache'):
del self._topologcally_sorted_indexes_cache
return self
@property
def topologically_sorted_indexes(self):
"""Iterate in topological order, based on networkx's topological_sort() function.
"""
try:
return self._topologcally_sorted_indexes_cache
except AttributeError:
seen = set()
order = []
explored = set()
for i in self.edges:
if i in explored:
continue
fringe = [i]
while fringe:
w = fringe[-1] # depth first search
if w in explored: # already looked down this branch
fringe.pop()
continue
seen.add(w) # mark as seen
# Check successors for cycles and for new nodes
new_nodes = []
for n in self.outputs_of(w):
if n not in explored:
if n in seen: # CYCLE !!
raise RuntimeError("Graph contains a cycle.")
new_nodes.append(n)
if new_nodes: # Add new_nodes to fringe
fringe.extend(new_nodes)
else: # No new nodes so w is fully explored
explored.add(w)
order.append(w)
fringe.pop() # done considering this node
self._topologcally_sorted_indexes_cache = tuple(filter(lambda i: type(i) is int, reversed(order)))
return self._topologcally_sorted_indexes_cache
def _resolve_index(self, mixed):
""" Find the index based on various strategies for a node, probably an input or output of chain. Supported inputs are indexes, node values or names.
"""
if mixed is None:
return None
if type(mixed) is int or mixed in self.edges:
return mixed
if isinstance(mixed, str) and mixed in self.named:
return self.named[mixed]
if mixed in self.nodes:
return self.nodes.index(mixed)
raise ValueError('Cannot find node matching {!r}.'.format(mixed))

View File

@ -15,11 +15,12 @@
# limitations under the License. # limitations under the License.
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from queue import Queue from queue import Queue
from bonobo.errors import AbstractError, InactiveWritableError, InactiveReadableError
from bonobo.constants import BEGIN, END from bonobo.constants import BEGIN, END
from bonobo.basics import noop from bonobo.errors import AbstractError, InactiveReadableError, InactiveWritableError
from bonobo.nodes import noop
BUFFER_SIZE = 8192 BUFFER_SIZE = 8192

View File

@ -1,31 +1,7 @@
import functools import functools
import struct
import sys
import warnings import warnings
def is_platform_little_endian():
""" am I little endian """
return sys.byteorder == 'little'
def is_platform_windows():
return sys.platform == 'win32' or sys.platform == 'cygwin'
def is_platform_linux():
return sys.platform == 'linux2'
def is_platform_mac():
return sys.platform == 'darwin'
def is_platform_32bit():
return struct.calcsize("P") * 8 < 64
def deprecated_alias(alias, func): def deprecated_alias(alias, func):
@functools.wraps(func) @functools.wraps(func)
def new_func(*args, **kwargs): def new_func(*args, **kwargs):

View File

@ -1,12 +0,0 @@
def console_run(*chain, output=True, plugins=None, strategy=None):
from bonobo import run
from bonobo.ext.console import ConsoleOutputPlugin
return run(*chain, plugins=(plugins or []) + [ConsoleOutputPlugin()] if output else [], strategy=strategy)
def jupyter_run(*chain, plugins=None, strategy=None):
from bonobo import run
from bonobo.ext.jupyter import JupyterOutputPlugin
return run(*chain, plugins=(plugins or []) + [JupyterOutputPlugin()], strategy=strategy)

View File

@ -1,3 +1,7 @@
import functools
from functools import partial
def get_name(mixed): def get_name(mixed):
try: try:
return mixed.__name__ return mixed.__name__
@ -27,175 +31,199 @@ class ValueHolder:
""" """
def __init__(self, value, *, type=None): def __init__(self, value):
self.value = value self._value = value
self.type = type
def __repr__(self): @property
return repr(self.value) def value(self):
# XXX deprecated
return self._value
def __lt__(self, other): def get(self):
return self.value < other return self._value
def __le__(self, other): def set(self, new_value):
return self.value <= other self._value = new_value
def __bool__(self):
return bool(self._value)
def __eq__(self, other): def __eq__(self, other):
return self.value == other return self._value == other
def __ne__(self, other): def __ne__(self, other):
return self.value != other return self._value != other
def __repr__(self):
return repr(self._value)
def __lt__(self, other):
return self._value < other
def __le__(self, other):
return self._value <= other
def __gt__(self, other): def __gt__(self, other):
return self.value > other return self._value > other
def __ge__(self, other): def __ge__(self, other):
return self.value >= other return self._value >= other
def __add__(self, other): def __add__(self, other):
return self.value + other return self._value + other
def __radd__(self, other): def __radd__(self, other):
return other + self.value return other + self._value
def __iadd__(self, other): def __iadd__(self, other):
self.value += other self._value += other
return self return self
def __sub__(self, other): def __sub__(self, other):
return self.value - other return self._value - other
def __rsub__(self, other): def __rsub__(self, other):
return other - self.value return other - self._value
def __isub__(self, other): def __isub__(self, other):
self.value -= other self._value -= other
return self return self
def __mul__(self, other): def __mul__(self, other):
return self.value * other return self._value * other
def __rmul__(self, other): def __rmul__(self, other):
return other * self.value return other * self._value
def __imul__(self, other): def __imul__(self, other):
self.value *= other self._value *= other
return self return self
def __matmul__(self, other): def __matmul__(self, other):
return self.value @ other return self._value @ other
def __rmatmul__(self, other): def __rmatmul__(self, other):
return other @ self.value return other @ self._value
def __imatmul__(self, other): def __imatmul__(self, other):
self.value @= other self._value @= other
return self return self
def __truediv__(self, other): def __truediv__(self, other):
return self.value / other return self._value / other
def __rtruediv__(self, other): def __rtruediv__(self, other):
return other / self.value return other / self._value
def __itruediv__(self, other): def __itruediv__(self, other):
self.value /= other self._value /= other
return self return self
def __floordiv__(self, other): def __floordiv__(self, other):
return self.value // other return self._value // other
def __rfloordiv__(self, other): def __rfloordiv__(self, other):
return other // self.value return other // self._value
def __ifloordiv__(self, other): def __ifloordiv__(self, other):
self.value //= other self._value //= other
return self return self
def __mod__(self, other): def __mod__(self, other):
return self.value % other return self._value % other
def __rmod__(self, other): def __rmod__(self, other):
return other % self.value return other % self._value
def __imod__(self, other): def __imod__(self, other):
self.value %= other self._value %= other
return self return self
def __divmod__(self, other): def __divmod__(self, other):
return divmod(self.value, other) return divmod(self._value, other)
def __rdivmod__(self, other): def __rdivmod__(self, other):
return divmod(other, self.value) return divmod(other, self._value)
def __pow__(self, other): def __pow__(self, other):
return self.value**other return self._value**other
def __rpow__(self, other): def __rpow__(self, other):
return other**self.value return other**self._value
def __ipow__(self, other): def __ipow__(self, other):
self.value **= other self._value **= other
return self return self
def __lshift__(self, other): def __lshift__(self, other):
return self.value << other return self._value << other
def __rlshift__(self, other): def __rlshift__(self, other):
return other << self.value return other << self._value
def __ilshift__(self, other): def __ilshift__(self, other):
self.value <<= other self._value <<= other
return self return self
def __rshift__(self, other): def __rshift__(self, other):
return self.value >> other return self._value >> other
def __rrshift__(self, other): def __rrshift__(self, other):
return other >> self.value return other >> self._value
def __irshift__(self, other): def __irshift__(self, other):
self.value >>= other self._value >>= other
return self return self
def __and__(self, other): def __and__(self, other):
return self.value & other return self._value & other
def __rand__(self, other): def __rand__(self, other):
return other & self.value return other & self._value
def __iand__(self, other): def __iand__(self, other):
self.value &= other self._value &= other
return self return self
def __xor__(self, other): def __xor__(self, other):
return self.value ^ other return self._value ^ other
def __rxor__(self, other): def __rxor__(self, other):
return other ^ self.value return other ^ self._value
def __ixor__(self, other): def __ixor__(self, other):
self.value ^= other self._value ^= other
return self return self
def __or__(self, other): def __or__(self, other):
return self.value | other return self._value | other
def __ror__(self, other): def __ror__(self, other):
return other | self.value return other | self._value
def __ior__(self, other): def __ior__(self, other):
self.value |= other self._value |= other
return self return self
def __neg__(self): def __neg__(self):
return -self.value return -self._value
def __pos__(self): def __pos__(self):
return +self.value return +self._value
def __abs__(self): def __abs__(self):
return abs(self.value) return abs(self._value)
def __invert__(self): def __invert__(self):
return ~self.value return ~self._value
def __len__(self):
return len(self._value)
def get_attribute_or_create(obj, attr, default):
try:
return getattr(obj, attr)
except AttributeError:
setattr(obj, attr, default)
return getattr(obj, attr)

22
bonobo/util/python.py Normal file
View File

@ -0,0 +1,22 @@
import inspect
import os
import runpy
class _RequiredModule:
def __init__(self, dct):
self.__dict__ = dct
class _RequiredModulesRegistry(dict):
def require(self, name):
if name not in self:
bits = name.split('.')
pathname = os.path.join(os.getcwd(), os.path.dirname(inspect.getfile(inspect.stack()[1][0])))
filename = os.path.join(pathname, *bits[:-1], bits[-1] + '.py')
self[name] = _RequiredModule(runpy.run_path(filename, run_name=name))
return self[name]
registry = _RequiredModulesRegistry()
require = registry.require

View File

@ -24,7 +24,8 @@ class WithStatistics:
return ((name, self.statistics[name]) for name in self.statistics_names) return ((name, self.statistics[name]) for name in self.statistics_names)
def get_statistics_as_string(self, *args, **kwargs): def get_statistics_as_string(self, *args, **kwargs):
return ' '.join(('{0}={1}'.format(name, cnt) for name, cnt in self.get_statistics(*args, **kwargs) if cnt > 0)) stats = tuple('{0}={1}'.format(name, cnt) for name, cnt in self.get_statistics(*args, **kwargs) if cnt > 0)
return (kwargs.get('prefix', '') + ' '.join(stats)) if len(stats) else ''
def increment(self, name): def increment(self, name):
self.statistics[name] += 1 self.statistics[name] += 1

View File

@ -3,10 +3,7 @@
{% block body %} {% block body %}
<div style="border: 2px solid red; font-weight: bold; margin: 1em; padding: 1em"> <div style="border: 2px solid red; font-weight: bold; margin: 1em; padding: 1em">
Bonobo is currently <strong>ALPHA</strong> software. That means that the doc is not finished, and that Bonobo is <strong>ALPHA</strong> software. Some APIs will change.
some APIs will change.<br>
There are a lot of missing sections, including comparison with other tools. But if you're looking for a
replacement for X, unless X is an ETL, bonobo is probably not what you want.
</div> </div>
<h1 style="text-align: center"> <h1 style="text-align: center">
@ -16,26 +13,12 @@
<p> <p>
{% trans %} {% trans %}
<strong>Bonobo</strong> is a line-by-line data-processing toolkit for python 3.5+ emphasizing simple and <strong>Bonobo</strong> is a line-by-line data-processing toolkit for python 3.5+ (extract-transform-load
atomic data transformations defined using a directed graph of plain old python callables (functions and framework) emphasizing simple and atomic data transformations defined using a directed graph of plain old
generators). python objects (functions, iterables, generators, ...).
{% endtrans %} {% endtrans %}
</p> </p>
<p>
{% trans %}
<strong>Bonobo</strong> is a extract-transform-load framework that uses python code to define transformations.
{% endtrans %}
</p>
<p>
{% trans %}
<strong>Bonobo</strong> is your own data-monkey army. Tedious and repetitive data-processing incoming? Give
it a try!
{% endtrans %}
</p>
<h2 style="margin-bottom: 0">{% trans %}Documentation{% endtrans %}</h2> <h2 style="margin-bottom: 0">{% trans %}Documentation{% endtrans %}</h2>
<table class="contentstable"> <table class="contentstable">
@ -95,8 +78,9 @@
</li> </li>
<li> <li>
{% trans %} {% trans %}
<b>Dependency injection:</b> Abstract the transformation dependencies to easily switch data sources and <b>Service injection:</b> Abstract the transformation dependencies to easily switch data sources and
used libraries, allowing to easily test your transformations. dependant libraries. You'll be able to specify the concrete implementations or configurations at
runtime, for example to switch a database connection string or an API endpoint.
{% endtrans %} {% endtrans %}
</li> </li>
<li> <li>
@ -107,7 +91,7 @@
</li> </li>
<li> <li>
{% trans %} {% trans %}
Work in progress: read the <a href="https://www.bonobo-project.org/roadmap">roadmap</a>. Bonobo is young, and the todo-list is huge. Read the <a href="https://www.bonobo-project.org/roadmap">roadmap</a>.
{% endtrans %} {% endtrans %}
</li> </li>
</ul> </ul>

View File

@ -10,6 +10,7 @@ There are a few things that you should know while writing transformations graphs
:maxdepth: 2 :maxdepth: 2
purity purity
transformations
services services
Third party integrations Third party integrations

17
docs/guide/plugins.rst Normal file
View File

@ -0,0 +1,17 @@
Plugins
=======
Graph level plugins
:::::::::::::::::::
Node level plugins
::::::::::::::::::
enhancers
node
-

View File

@ -1,20 +1,18 @@
Services and dependencies (draft implementation) Services and dependencies
================================================ =========================
:Status: Draft implementation :Last-Modified: 20 may 2017
:Stability: Alpha
:Last-Modified: 28 apr 2017
Most probably, you'll want to use external systems within your transformations. Those systems may include databases, You'll probably want to use external systems within your transformations. Those systems may include databases, apis
apis (using http, for example), filesystems, etc. (using http, for example), filesystems, etc.
You can start by hardcoding those services. That does the job, at first. You can start by hardcoding those services. That does the job, at first.
If you're going a little further than that, you'll feel limited, for a few reasons: If you're going a little further than that, you'll feel limited, for a few reasons:
* Hardcoded and tightly linked dependencies make your transformations hard to test, and hard to reuse. * Hardcoded and tightly linked dependencies make your transformations hard to test, and hard to reuse.
* Processing data on your laptop is great, but being able to do it on different systems (or stages), in different * Processing data on your laptop is great, but being able to do it on different target systems (or stages), in different
environments, is more realistic? You probably want to contigure a different database on a staging environment, environments, is more realistic. You'll want to contigure a different database on a staging environment,
preprod environment or production system. Maybe you have silimar systems for different clients and want to select preprod environment or production system. Maybe you have silimar systems for different clients and want to select
the system at runtime. Etc. the system at runtime. Etc.
@ -52,10 +50,11 @@ injected to your calls under the parameter name "database".
Function-based transformations Function-based transformations
------------------------------ ------------------------------
No implementation yet, but expect something similar to CBT API, maybe using a `@Service(...)` decorator. No implementation yet, but expect something similar to CBT API, maybe using a `@Service(...)` decorator. See
`issue #70 <https://github.com/python-bonobo/bonobo/issues/70>`_.
Execution Provide implementation at run time
--------- ----------------------------------
Let's see how to execute it: Let's see how to execute it:

View File

@ -0,0 +1,93 @@
Transformations
===============
Here is some guidelines on how to write transformations, to avoid the convention-jungle that could happen without
a few rules.
Naming conventions
::::::::::::::::::
The naming convention used is the following.
If you're naming something which is an actual transformation, that can be used directly as a graph node, then use
underscores and lowercase names:
.. code-block:: python
# instance of a class based transformation
filter = Filter(...)
# function based transformation
def uppercase(s: str) -> str:
return s.upper()
If you're naming something which is configurable, that will need to be instanciated or called to obtain something that
can be used as a graph node, then use camelcase names:
.. code-block:: python
# configurable
class ChangeCase(Configurable):
modifier = Option(default='upper')
def call(self, s: str) -> str:
return getattr(s, self.modifier)()
# transformation factory
def Apply(method):
@functools.wraps(method)
def apply(s: str) -> str:
return method(s)
return apply
# result is a graph node candidate
upper = Apply(str.upper)
Function based transformations
::::::::::::::::::::::::::::::
The most basic transformations are function-based. Which means that you define a function, and it will be used directly
in a graph.
.. code-block:: python
def get_representation(row):
return repr(row)
graph = bonobo.Graph(
[...],
get_representation,
)
It does not allow any configuration, but if it's an option, prefer it as it's simpler to write.
Class based transformations
:::::::::::::::::::::::::::
A lot of logic is a bit more complex, and you'll want to use classes to define some of your transformations.
The :class:`bonobo.config.Configurable` class gives you a few toys to write configurable transformations.
Options
-------
.. autoclass:: bonobo.config.Option
Services
--------
.. autoclass:: bonobo.config.Service
Methods
-------
.. autoclass:: bonobo.config.Method
ContextProcessors
-----------------
.. autoclass:: bonobo.config.ContextProcessor

View File

@ -70,7 +70,7 @@ Utils
Count Count
----- -----
.. automodule:: bonobo.examples.utils.count .. automodule:: bonobo.examples.nodes.count
:members: :members:
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:

View File

@ -1,6 +1,27 @@
Detailed roadmap Detailed roadmap
================ ================
initialize / finalize better than start / stop ?
Graph and node level plugins
::::::::::::::::::::::::::::
* Enhancers or nide-level plugins
* Graph level plugins
* Documentation
Command line interface and environment
::::::::::::::::::::::::::::::::::::::
* How do we manage environment ? .env ?
* How do we configure plugins ?
* Console run should allow console plugin as a command line argument (or silence it).
Services and Processors
:::::::::::::::::::::::
* ContextProcessors not clean
Next... Next...
::::::: :::::::
@ -10,8 +31,13 @@ Next...
* Windows break because of readme encoding. Fix in edgy. * Windows break because of readme encoding. Fix in edgy.
* bonobo init --with sqlalchemy,docker * bonobo init --with sqlalchemy,docker
* logger, vebosity level * logger, vebosity level
* Console run should allow console plugin as a command line argument (or silence it).
* ContextProcessors not clean
External libs that looks good
:::::::::::::::::::::::::::::
* dask.distributed
* mediator (event dispatcher)
Version 0.3 Version 0.3
::::::::::: :::::::::::

View File

@ -1,23 +1,29 @@
First steps First steps
=========== ===========
We tried hard to make **Bonobo** simple. We use simple python, and we believe it should be simple to learn. Bonobo uses simple python and should be quick and easy to learn.
What is Bonobo?
:::::::::::::::
Bonobo is an ETL (Extract-Transform-Load) framework for python 3.5. The goal is to define data-transformations, with
python code in charge of handling similar shaped independant lines of data.
Bonobo *is not* a statistical or data-science tool. If you're looking for a data-analysis tool in python, use Pandas.
Bonobo is a lean manufacturing assembly line for data that let you focus on the actual work instead of the plumbery.
Tutorial Tutorial
:::::::: ::::::::
We strongly advice that even if you're an advanced python developper, you go through the whole tutorial for two
reasons: that should be sufficient to do anything possible with **Bonobo** and that's a good moment to learn the few
concepts you'll see everywhere in the software.
If you're not familiar with python, you should first read :doc:`python`.
.. toctree:: .. toctree::
:maxdepth: 2 :maxdepth: 2
tut01 tut01
tut02 tut02
What's next? What's next?
:::::::::::: ::::::::::::
@ -39,3 +45,4 @@ Read about integrating external tools with bonobo
* :doc:`../guide/ext/jupyter`: run transformations within jupyter notebooks. * :doc:`../guide/ext/jupyter`: run transformations within jupyter notebooks.
* :doc:`../guide/ext/selenium`: run * :doc:`../guide/ext/selenium`: run
* :doc:`../guide/ext/sqlalchemy`: everything you need to interract with SQL databases. * :doc:`../guide/ext/sqlalchemy`: everything you need to interract with SQL databases.

View File

@ -1 +1,27 @@
-e .[dev] -e .[dev]
alabaster==0.7.10
astroid==1.5.2
babel==2.4.0
coverage==4.3.4
docutils==0.13.1
imagesize==0.7.1
isort==4.2.5
jinja2==2.9.6
lazy-object-proxy==1.2.2
markupsafe==1.0
mccabe==0.6.1
py==1.4.33
pygments==2.2.0
pylint==1.7.1
pytest-cov==2.4.0
pytest-timeout==1.2.0
pytest==3.0.7
pytz==2017.2
requests==2.13.0
six==1.10.0
snowballstemmer==1.2.1
sphinx-rtd-theme==0.2.4
sphinx==1.5.5
wrapt==1.10.10
yapf==0.16.1

41
requirements-jupyter.txt Normal file
View File

@ -0,0 +1,41 @@
-e .[jupyter]
appnope==0.1.0
bleach==2.0.0
decorator==4.0.11
entrypoints==0.2.2
html5lib==0.999999999
ipykernel==4.6.1
ipython-genutils==0.2.0
ipython==6.0.0
ipywidgets==6.0.0
jedi==0.10.2
jinja2==2.9.6
jsonschema==2.6.0
jupyter-client==5.0.1
jupyter-console==5.1.0
jupyter-core==4.3.0
jupyter==1.0.0
markupsafe==1.0
mistune==0.7.4
nbconvert==5.1.1
nbformat==4.3.0
notebook==5.0.0
pandocfilters==1.4.1
pexpect==4.2.1
pickleshare==0.7.4
prompt-toolkit==1.0.14
ptyprocess==0.5.1
pygments==2.2.0
python-dateutil==2.6.0
pyzmq==16.0.2
qtconsole==4.3.0
simplegeneric==0.8.1
six==1.10.0
terminado==0.6
testpath==0.3
tornado==4.5.1
traitlets==4.3.2
wcwidth==0.1.7
webencodings==0.5.1
widgetsnbextension==2.0.0

View File

@ -1,7 +1,12 @@
-e . -e .
appdirs==1.4.3
colorama==0.3.9 colorama==0.3.9
enum34==1.1.6
fs==2.0.3 fs==2.0.3
pbr==3.0.0
psutil==5.2.2 psutil==5.2.2
pytz==2017.2
requests==2.13.0 requests==2.13.0
six==1.10.0
stevedore==1.21.0 stevedore==1.21.0

View File

@ -1,22 +1,11 @@
# This file is autogenerated by edgy.project code generator. # This file is autogenerated by edgy.project code generator.
# All changes will be overwritten. # All changes will be overwritten.
import os
from setuptools import setup, find_packages from setuptools import setup, find_packages
from codecs import open
from os import path
root_dir = os.path.dirname(os.path.abspath(__file__)) here = path.abspath(path.dirname(__file__))
tolines = lambda c: list(filter(None, map(lambda s: s.strip(), c.split('\n'))))
def read(filename, flt=None):
try:
with open(filename) as f:
content = f.read().strip()
return flt(content) if callable(flt) else content
except EnvironmentError:
return ''
# Py3 compatibility hacks, borrowed from IPython. # Py3 compatibility hacks, borrowed from IPython.
try: try:
@ -28,9 +17,18 @@ except NameError:
exec(compile(open(fname).read(), fname, "exec"), globs, locs) exec(compile(open(fname).read(), fname, "exec"), globs, locs)
# Get the long description from the README file
with open(path.join(here, 'README.rst'), encoding='utf-8') as f:
long_description = f.read()
# Get the classifiers from the classifiers file
tolines = lambda c: list(filter(None, map(lambda s: s.strip(), c.split('\n'))))
with open(path.join(here, 'classifiers.txt'), encoding='utf-8') as f:
classifiers = tolines(f.read())
version_ns = {} version_ns = {}
try: try:
execfile(os.path.join(root_dir, 'bonobo/_version.py'), version_ns) execfile(path.join(here, 'bonobo/_version.py'), version_ns)
except EnvironmentError: except EnvironmentError:
version = 'dev' version = 'dev'
else: else:
@ -38,44 +36,41 @@ else:
setup( setup(
name='bonobo', name='bonobo',
description= description=('Bonobo, a simple, modern and atomic extract-transform-load toolkit for '
('Bonobo, a simple, modern and atomic extract-transform-load toolkit for '
'python 3.5+.'), 'python 3.5+.'),
license='Apache License, Version 2.0', license='Apache License, Version 2.0',
install_requires=[ install_requires=[
'colorama >=0.3,<1.0', 'fs >=2.0,<3.0', 'psutil >=5.2,<6.0', 'colorama >=0.3,<1.0', 'fs >=2.0,<3.0', 'psutil >=5.2,<6.0', 'requests >=2.0,<3.0', 'stevedore >=1.21,<2.0'
'requests >=2.0,<3.0', 'stevedore >=1.21,<2.0'
], ],
version=version, version=version,
long_description=read('README.rst'), long_description=long_description,
classifiers=read('classifiers.txt', tolines), classifiers=classifiers,
packages=find_packages(exclude=['ez_setup', 'example', 'test']), packages=find_packages(exclude=['ez_setup', 'example', 'test']),
include_package_data=True, include_package_data=True,
data_files=[('share/jupyter/nbextensions/bonobo-jupyter', [ data_files=[
'bonobo/ext/jupyter/static/extension.js', (
'bonobo/ext/jupyter/static/index.js', 'share/jupyter/nbextensions/bonobo-jupyter', [
'bonobo/ext/jupyter/static/extension.js', 'bonobo/ext/jupyter/static/index.js',
'bonobo/ext/jupyter/static/index.js.map' 'bonobo/ext/jupyter/static/index.js.map'
])], ]
)
],
extras_require={ extras_require={
'dev': [ 'dev': [
'coverage >=4,<5', 'pylint >=1,<2', 'pytest >=3,<4', 'coverage >=4,<5', 'pylint >=1,<2', 'pytest >=3,<4', 'pytest-cov >=2,<3', 'pytest-timeout >=1,<2', 'sphinx',
'pytest-cov >=2,<3', 'pytest-timeout >=1,<2', 'sphinx',
'sphinx_rtd_theme', 'yapf' 'sphinx_rtd_theme', 'yapf'
], ],
'jupyter': ['jupyter >=1.0,<1.1', 'ipywidgets >=6.0.0.beta5'] 'jupyter': ['jupyter >=1.0,<1.1', 'ipywidgets >=6.0.0.beta5']
}, },
entry_points={ entry_points={
'bonobo.commands': [ 'bonobo.commands': [
'init = bonobo.commands.init:register', 'init = bonobo.commands.init:register', 'run = bonobo.commands.run:register',
'run = bonobo.commands.run:register',
'version = bonobo.commands.version:register' 'version = bonobo.commands.version:register'
], ],
'console_scripts': ['bonobo = bonobo.commands:entrypoint'], 'console_scripts': ['bonobo = bonobo.commands:entrypoint'],
'edgy.project.features': 'edgy.project.features': ['bonobo = '
['bonobo = '
'bonobo.ext.edgy.project.feature:BonoboFeature'] 'bonobo.ext.edgy.project.feature:BonoboFeature']
}, },
url='https://www.bonobo-project.org/', url='https://www.bonobo-project.org/',
download_url= download_url='https://github.com/python-bonobo/bonobo/tarball/{version}'.format(version=version),
'https://github.com/python-bonobo/bonobo/tarball/{version}'.format( )
version=version), )

View File

@ -12,7 +12,7 @@ def test_write_csv_to_file(tmpdir):
writer = CsvWriter(path=filename) writer = CsvWriter(path=filename)
context = NodeExecutionContext(writer, services={'fs': fs}) context = NodeExecutionContext(writer, services={'fs': fs})
context.recv(BEGIN, Bag({'foo': 'bar'}), Bag({'foo': 'baz', 'ignore': 'this'}), END) context.write(BEGIN, Bag({'foo': 'bar'}), Bag({'foo': 'baz', 'ignore': 'this'}), END)
context.start() context.start()
context.step() context.step()
@ -34,7 +34,7 @@ def test_read_csv_from_file(tmpdir):
context = CapturingNodeExecutionContext(reader, services={'fs': fs}) context = CapturingNodeExecutionContext(reader, services={'fs': fs})
context.start() context.start()
context.recv(BEGIN, Bag(), END) context.write(BEGIN, Bag(), END)
context.step() context.step()
context.stop() context.stop()

View File

@ -20,7 +20,7 @@ def test_file_writer_in_context(tmpdir, lines, output):
context = NodeExecutionContext(writer, services={'fs': fs}) context = NodeExecutionContext(writer, services={'fs': fs})
context.start() context.start()
context.recv(BEGIN, *map(Bag, lines), END) context.write(BEGIN, *map(Bag, lines), END)
for _ in range(len(lines)): for _ in range(len(lines)):
context.step() context.step()
context.stop() context.stop()
@ -48,7 +48,7 @@ def test_file_reader_in_context(tmpdir):
context = CapturingNodeExecutionContext(reader, services={'fs': fs}) context = CapturingNodeExecutionContext(reader, services={'fs': fs})
context.start() context.start()
context.recv(BEGIN, Bag(), END) context.write(BEGIN, Bag(), END)
context.step() context.step()
context.stop() context.stop()

View File

@ -13,7 +13,7 @@ def test_write_json_to_file(tmpdir):
context = NodeExecutionContext(writer, services={'fs': fs}) context = NodeExecutionContext(writer, services={'fs': fs})
context.start() context.start()
context.recv(BEGIN, Bag({'foo': 'bar'}), END) context.write(BEGIN, Bag({'foo': 'bar'}), END)
context.step() context.step()
context.stop() context.stop()
@ -34,7 +34,7 @@ def test_read_json_from_file(tmpdir):
context = CapturingNodeExecutionContext(reader, services={'fs': fs}) context = CapturingNodeExecutionContext(reader, services={'fs': fs})
context.start() context.start()
context.recv(BEGIN, Bag(), END) context.write(BEGIN, Bag(), END)
context.step() context.step()
context.stop() context.stop()

View File

@ -1,7 +1,9 @@
import pickle
from unittest.mock import Mock from unittest.mock import Mock
from bonobo import Bag from bonobo import Bag
from bonobo.constants import INHERIT_INPUT from bonobo.constants import INHERIT_INPUT
from bonobo.structs import Token
args = ('foo', 'bar', ) args = ('foo', 'bar', )
kwargs = dict(acme='corp') kwargs = dict(acme='corp')
@ -59,6 +61,37 @@ def test_inherit():
assert bag4.flags is () assert bag4.flags is ()
def test_pickle():
bag1 = Bag('a', a=1)
bag2 = Bag.inherit('b', b=2, _parent=bag1)
bag3 = bag1.extend('c', c=3)
bag4 = Bag('d', d=4)
# XXX todo this probably won't work with inheriting bags if parent is not there anymore? maybe that's not true
# because the parent may be in the serialization output but we need to verify this assertion.
for bag in bag1, bag2, bag3, bag4:
pickled = pickle.dumps(bag)
unpickled = pickle.loads(pickled)
assert unpickled == bag
def test_eq_operator():
assert Bag('foo') == Bag('foo')
assert Bag('foo') != Bag('bar')
assert Bag('foo') is not Bag('foo')
assert Bag('foo') != Token('foo')
assert Token('foo') != Bag('foo')
def test_repr(): def test_repr():
bag = Bag('a', a=1) bag = Bag('a', a=1)
assert repr(bag) == "<Bag ('a', a=1)>" assert repr(bag) == "<Bag ('a', a=1)>"
def test_iterator():
bag = Bag()
assert list(bag.apply([1, 2, 3])) == [1, 2, 3]
assert list(bag.apply((1, 2, 3))) == [1, 2, 3]
assert list(bag.apply(range(5))) == [0, 1, 2, 3, 4]
assert list(bag.apply('azerty')) == ['a', 'z', 'e', 'r', 't', 'y']

View File

@ -1,5 +1,7 @@
import pytest import pytest
from unittest.mock import sentinel
from bonobo.constants import BEGIN from bonobo.constants import BEGIN
from bonobo.structs import Graph from bonobo.structs import Graph
@ -41,3 +43,31 @@ def test_graph_add_chain():
g.add_chain(identity, identity, identity) g.add_chain(identity, identity, identity)
assert len(g.nodes) == 3 assert len(g.nodes) == 3
assert len(g.outputs_of(BEGIN)) == 1 assert len(g.outputs_of(BEGIN)) == 1
def test_graph_topological_sort():
g = Graph()
g.add_chain(
sentinel.a1,
sentinel.a2,
sentinel.a3,
_input=None,
_output=None,
)
assert g.topologically_sorted_indexes == (0, 1, 2)
assert g[0] == sentinel.a1
assert g[1] == sentinel.a2
assert g[2] == sentinel.a3
g.add_chain(
sentinel.b1,
sentinel.b2,
_output=sentinel.a2,
)
assert g.topologically_sorted_indexes[-2:] == (1, 2)
assert g.topologically_sorted_indexes.index(3) < g.topologically_sorted_indexes.index(4)
assert g[3] == sentinel.b1
assert g[4] == sentinel.b2

View File

@ -19,8 +19,8 @@ from queue import Empty
import pytest import pytest
from bonobo.constants import BEGIN, END from bonobo.constants import BEGIN, END
from bonobo.core.inputs import Input
from bonobo.errors import InactiveWritableError, InactiveReadableError from bonobo.errors import InactiveWritableError, InactiveReadableError
from bonobo.structs.inputs import Input
def test_input_runlevels(): def test_input_runlevels():

View File

@ -1,7 +1,8 @@
from unittest.mock import MagicMock from unittest.mock import MagicMock
import bonobo
import pytest import pytest
import bonobo
from bonobo.config.processors import ContextCurrifier from bonobo.config.processors import ContextCurrifier
from bonobo.constants import NOT_MODIFIED from bonobo.constants import NOT_MODIFIED
@ -12,12 +13,9 @@ def test_count():
context = MagicMock() context = MagicMock()
currified = ContextCurrifier(bonobo.count) with ContextCurrifier(bonobo.count).as_contextmanager(context) as stack:
currified.setup(context)
for i in range(42): for i in range(42):
currified() stack()
currified.teardown()
assert len(context.method_calls) == 1 assert len(context.method_calls) == 1
bag = context.send.call_args[0][0] bag = context.send.call_args[0][0]
@ -32,18 +30,32 @@ def test_identity():
def test_limit(): def test_limit():
limit = bonobo.Limit(2) context, results = MagicMock(), []
results = []
with ContextCurrifier(bonobo.Limit(2)).as_contextmanager(context) as stack:
for i in range(42): for i in range(42):
results += list(limit()) results += list(stack())
assert results == [NOT_MODIFIED] * 2 assert results == [NOT_MODIFIED] * 2
def test_limit_not_there(): def test_limit_not_there():
limit = bonobo.Limit(42) context, results = MagicMock(), []
results = []
with ContextCurrifier(bonobo.Limit(42)).as_contextmanager(context) as stack:
for i in range(10): for i in range(10):
results += list(limit()) results += list(stack())
assert results == [NOT_MODIFIED] * 10
def test_limit_default():
context, results = MagicMock(), []
with ContextCurrifier(bonobo.Limit()).as_contextmanager(context) as stack:
for i in range(20):
results += list(stack())
assert results == [NOT_MODIFIED] * 10 assert results == [NOT_MODIFIED] * 10

View File

@ -0,0 +1,84 @@
import pytest
from bonobo.config import Configurable, Method, Option
from bonobo.errors import ConfigurationError
class MethodBasedConfigurable(Configurable):
handler = Method()
foo = Option(positional=True)
bar = Option()
def call(self, *args, **kwargs):
self.handler(*args, **kwargs)
def test_one_wrapper_only():
with pytest.raises(ConfigurationError):
class TwoMethods(Configurable):
h1 = Method()
h2 = Method()
def test_define_with_decorator():
calls = []
@MethodBasedConfigurable
def Concrete(self, *args, **kwargs):
calls.append((args, kwargs, ))
print('handler', Concrete.handler)
assert callable(Concrete.handler)
t = Concrete('foo', bar='baz')
assert callable(t.handler)
assert len(calls) == 0
t()
assert len(calls) == 1
def test_define_with_argument():
calls = []
def concrete_handler(*args, **kwargs):
calls.append((args, kwargs, ))
t = MethodBasedConfigurable('foo', bar='baz', handler=concrete_handler)
assert callable(t.handler)
assert len(calls) == 0
t()
assert len(calls) == 1
def test_define_with_inheritance():
calls = []
class Inheriting(MethodBasedConfigurable):
def handler(self, *args, **kwargs):
calls.append((args, kwargs, ))
t = Inheriting('foo', bar='baz')
assert callable(t.handler)
assert len(calls) == 0
t()
assert len(calls) == 1
def test_inheritance_then_decorate():
calls = []
class Inheriting(MethodBasedConfigurable):
pass
@Inheriting
def Concrete(self, *args, **kwargs):
calls.append((args, kwargs, ))
assert callable(Concrete.handler)
t = Concrete('foo', bar='baz')
assert callable(t.handler)
assert len(calls) == 0
t()
assert len(calls) == 1

View File

@ -59,11 +59,23 @@ def test_simple_execution_context():
assert ctx[i].wrapped is node assert ctx[i].wrapped is node
assert not ctx.alive assert not ctx.alive
assert not ctx.started
assert not ctx.stopped
ctx.recv(BEGIN, Bag(), END) ctx.recv(BEGIN, Bag(), END)
assert not ctx.alive assert not ctx.alive
assert not ctx.started
assert not ctx.stopped
ctx.start() ctx.start()
assert ctx.alive assert ctx.alive
assert ctx.started
assert not ctx.stopped
ctx.stop()
assert not ctx.alive
assert ctx.started
assert ctx.stopped

View File

@ -0,0 +1 @@
foo = 'bar'

23
tests/util/test_compat.py Normal file
View File

@ -0,0 +1,23 @@
import pytest
from bonobo.util.compat import deprecated, deprecated_alias
def test_deprecated():
@deprecated
def foo():
pass
foo = deprecated(foo)
with pytest.warns(DeprecationWarning):
foo()
def test_deprecated_alias():
def foo():
pass
foo = deprecated_alias('bar', foo)
with pytest.warns(DeprecationWarning):
foo()

View File

@ -35,6 +35,7 @@ def test_wrapper_name():
def test_valueholder(): def test_valueholder():
x = ValueHolder(42) x = ValueHolder(42)
assert x == 42 assert x == 42
x += 1 x += 1
assert x == 43 assert x == 43

View File

@ -0,0 +1,6 @@
from bonobo.util.python import require
def test_require():
dummy = require('requireable.dummy')
assert dummy.foo == 'bar'

View File

@ -1,4 +1,4 @@
from bonobo.core.statistics import WithStatistics from bonobo.util.statistics import WithStatistics
class MyThingWithStats(WithStatistics): class MyThingWithStats(WithStatistics):