text: Title: Feature Request: Inversion of Control features should be supported in notebooks
  Body: We're working on adopting ploomber as our pipeline management technology. In early experimentation, I've found that many of the best inversion of control features of ploomber don't seem to be supported for notebooks. I find this odd because of the amount of attention and ink spent on integrating jupyter notebooks.
  
  Examples (in order of importance):
  - Serializer and deserializer don't appear to be supported for notebook upstreams and products. They seem to always be paths that the notebook author must handle.
  - Clients don't appear to be supported for notebooks. It's only possible to manually instantiate them in the notebook.
  - Injection substitutes absolute paths. This results in multiple editors accidentally fighting over the upstream and product cells in source control even if no real edits are made to the notebook.
  
  The extensions you've added to make a jupyter[lab] server work well with plain 'ol .py files are very useful but I was disappointed in the small subset of features available to notebook tasks. This breaks the most powerful features of ploomber when using notebooks. Pure python tasks can use clients and serializers to improve testability and make large changes possible with tiny reliable changes to the pipeline spec. You can develop using human-readable formats and the local filesystem and then use binary formats and cloud storage in production with a couple of lines of yaml when using pure python but this is not possible with notebooks. Further, ploomber teaches certain concepts and expectations around upstreams and products when using python tasks that are not valid when using notebooks.
  
  Suggestion: abstract upstream and product into python objects you import instead of injecting dictionaries of strings into notebooks.
  
  ```python
  # %% tags=["parameters"]
  # add default values for parameters here
  
  # %% tags=["injected-parameters"]
  # Parameters
  upstream = {
      "input": "\some\wild\absolute-path\input.csv"
  }
  product = {
      "data": "\some\wild\absolute-path\data.csv",
      "nb": "\some\wild\absolute-path\nb.csv",
  }
  
  # %%
  df = pandas.read_csv(upstream['input'])
  result = do_some_stuff(df)
  result.to_csv(product['data'])
  ```
  
  could become:
  ```python
  # %% tags=["parameters"]
  # add default values for parameters here
  upstream, product = {}, {}
  
  # %% tags=["injected-parameters"]
  # Parameters
  upstream, product = ploomber.nb.get_context() # knows the current state of the pipeline and uses it to populate upstream and product
  
  # %%
  df = upstream['input'] # deserializer and client populate the object instead of the path
  result = do_some_stuff(df)
  product['data'] = result # serializer and client encode and store the result instead of the notebook doing it using a path
  ```