Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release 2.0 #285

Merged
merged 5 commits into from
Jul 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ If it's your first time working on this library, clone the repo, set up pre-comm

```sh
% make setup
% tox
% make test
```

### Reporting a bug
Expand Down
2 changes: 1 addition & 1 deletion docs/source/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Changelog
=========

2.0.0 (TBD)
2.0.0 (2023-07-31)
------------------------

See :ref:`Migrating from 1.x to 2.0` for detailed migration notes.
Expand Down
41 changes: 41 additions & 0 deletions docs/source/contributing.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
=================
Contributing
=================

Everyone who has an idea or suggestion is welcome to contribute! As maintainers, we expect our community of users and contributors to adhere to the guidelines and expectations set forth in the `Contributor Covenant <https://www.contributor-covenant.org/version/2/1/code_of_conduct/>`_. Be kind and empathetic, respect differing opinions, and stay focused on what is best for the community.

Setting up your environment
==============================

If it's your first time working on this library, clone the repo, set up pre-commit hooks, and make sure you can run tests (and they pass). If that doesn't work out of the box, please check your local development environment before filing an issue.

.. code-block:: shell

% make setup
% make test
% make docs

Reporting a bug
=====================

We encourage anyone to `submit an issue <https://github.com/gtalarico/pyairtable/issues/new>`_ to let us know about bugs, as long as you've followed these steps:

1. Confirm you're on the latest version of the library and you can run the test suite locally.
2. Check `open issues <https://github.com/gtalarico/pyairtable/issues>`_ to see if someone else has already reported it.
3. Provide as much context as possible, i.e. expected vs. actual behavior, steps to reproduce, and runtime environment.
4. If possible, reproduce the problem in a small example that you can share in the issue summary.

We ask that you *never* report security vulnerabilities to the GitHub issue tracker. Sensitive issues of this nature must be sent directly to the maintainers via email.

Submitting a patch
=====================

Anyone who uses this library is welcome to `submit a pull request <https://github.com/gtalarico/pyairtable/pulls>`_ for a bug fix or a new feature. We do ask that all pull requests adhere to the following guidelines:

1. Public functions/methods have docstrings and type annotations.
2. New functionality is accompanied by clear, descriptive unit tests.
3. You can run ``make test && make docs`` successfully.

If you want to discuss an idea you're working on but haven't yet finished all of the above, please `open a draft pull request <https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests#draft-pull-requests>`_. That will be a clear signal that you're not asking to merge your code (yet) and are just looking for discussion or feedback.

Thanks in advance for sharing your ideas!
3 changes: 2 additions & 1 deletion docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ pyAirtable

about
changelog
Airtable API Docs <https://airtable.com/api>
contributing
GitHub <https://github.com/gtalarico/pyairtable>
PyPI <https://pypi.org/project/pyairtable/>
Airtable API <https://airtable.com/api>
36 changes: 35 additions & 1 deletion docs/source/migrations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,30 @@ See below for supported and unsupported patterns:
# to avoid situations where self.api and self.base don't align.
>>> table = Table(api, base_id, table_name) # [Api, Base, str]

Retry by Default
You may need to change how your code looks up some pieces of connection metadata; for example:

.. list-table::
:header-rows: 1

* - Method/attribute in 1.5
- Method/attribute in 2.0
* - ``base.base_id``
- :data:`base.id <pyairtable.Base.id>`
* - ``table.table_name``
- :data:`table.name <pyairtable.Table.name>`
* - ``table.get_base()``
- :data:`table.base <pyairtable.Table.base>`
* - ``table.base_id``
- :data:`table.base.id <pyairtable.Base.id>`
* - ``table.table_url``
- :meth:`table.url <pyairtable.Table.url>`
* - ``table.get_record_url()``
- :meth:`table.record_url() <pyairtable.Table.record_url>`

There is no fully exhaustive list of changes; please refer to
:ref:`the API documentation <Module: pyairtable>` for a list of available methods and attributes.

Retry by default
----------------

* By default, the library will retry requests up to five times if it receives
Expand Down Expand Up @@ -80,6 +103,16 @@ batch_upsert has a different return type
See :class:`~pyairtable.api.types.UpsertResultDict` for more details.


Found a problem?
--------------------

While these breaking changes were intentional, it is very possible that the 2.0 release has bugs.
Please take a moment to :ref:`read our contribution guidelines <contributing>` before submitting an issue.


------


Migrating from 0.x to 1.0
============================

Expand All @@ -90,6 +123,7 @@ The last ``0.x`` release will remain available on `PyPI <https://pypi.org/projec

You can read about the reasons behind the renaming `here <https://github.com/gtalarico/airtable-python-wrapper/issues/125#issuecomment-891439661>`__.


New Features in 1.0
-------------------

Expand Down
2 changes: 1 addition & 1 deletion pyairtable/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "2.0.0rc1"
__version__ = "2.0.0"

from .api import Api, Base, Table
from .api.retrying import retry_strategy
Expand Down
3 changes: 3 additions & 0 deletions pyairtable/api/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ class Base:
Represents an Airtable base.
"""

#: The connection to the Airtable API.
api: "pyairtable.api.api.Api"

#: The base ID, in the format ``appXXXXXXXXXXXXXX``
id: str

def __init__(self, api: Union["pyairtable.api.api.Api", str], base_id: str):
Expand Down
16 changes: 12 additions & 4 deletions pyairtable/api/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ class Table:
>>> records = table.all()
"""

api: "pyairtable.api.api.Api"
#: The base that this table belongs to.
base: "pyairtable.api.base.Base"

#: Can be either the table name or the table ID (``tblXXXXXXXXXXXXXX``).
name: str

@overload
Expand Down Expand Up @@ -98,7 +100,6 @@ def __init__(
f" got ({type(api_key)}, {type(base_id)}, {type(table_name)})"
)

self.api = base.api
self.base = base
self.name = table_name

Expand All @@ -108,16 +109,23 @@ def __repr__(self) -> str:
@property
def url(self) -> str:
"""
Return the URL for this table.
Returns the URL for this table.
"""
return self.api.build_url(self.base.id, urllib.parse.quote(self.name, safe=""))

def record_url(self, record_id: RecordId, *components: str) -> str:
"""
Return the URL for the given record ID, with optional trailing components.
Returns the URL for the given record ID, with optional trailing components.
"""
return posixpath.join(self.url, record_id, *components)

@property
def api(self) -> "pyairtable.api.api.Api":
"""
Returns the same API connection as table's :class:`~pyairtable.Base`.
"""
return self.base.api

def get(self, record_id: RecordId, **options: Any) -> RecordDict:
"""
Retrieves a record by its ID.
Expand Down
92 changes: 61 additions & 31 deletions pyairtable/orm/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,40 +20,52 @@

class Model:
"""
This class allows you create an ORM-style class for your Airtable tables.
Supports creating ORM-style classes representing Airtable tables.
For more details, see :ref:`orm`.

This is a metaclass and can only be used to define sub-classes.
A nested class called ``Meta`` is required and can specify
the following attributes:

The ``Meta`` is reuired and must specify all three attributes: ``base_id``,
``table_id``, and ``api_key``.
* ``api_key`` (required) - API key or personal access token.
* ``base_id`` (required) - Base ID (not name).
* ``table_name`` (required) - Table ID or name.
* ``timeout`` - A tuple indicating a connect and read timeout. Defaults to no timeout.
* ``typecast`` - |kwarg_typecast| Defaults to ``True``.

>>> from pyairtable.orm import Model, fields
>>> class Contact(Model):
... first_name = fields.TextField("First Name")
... age = fields.IntegerField("Age")
...
... class Meta:
... base_id = "appaPqizdsNHDvlEm"
... table_name = "Contact"
... api_key = "keyapikey"
... timeout: Optional[Tuple[int, int]] = (5, 5)
... typecast: bool = True
.. code-block:: python

from pyairtable.orm import Model, fields

class Contact(Model):
first_name = fields.TextField("First Name")
age = fields.IntegerField("Age")

class Meta:
base_id = "appaPqizdsNHDvlEm"
table_name = "Contact"
api_key = "keyapikey"
timeout = (5, 5)
typecast = True

You can implement meta attributes as callables if certain values
need to be dynamically provided or are unavailable at import time:

>>> from pyairtable.orm import Model, fields
>>> class Contact(Model):
... first_name = fields.TextField("First Name")
... age = fields.IntegerField("Age")
...
... class Meta:
... base_id = "appaPqizdsNHDvlEm"
... table_name = "Contact"
...
... @staticmethod
... def api_key():
... return os.environ["AIRTABLE_API_KEY"]
.. code-block:: python

from pyairtable.orm import Model, fields
from your_app.config import get_secret

class Contact(Model):
first_name = fields.TextField("First Name")
age = fields.IntegerField("Age")

class Meta:
base_id = "appaPqizdsNHDvlEm"
table_name = "Contact"

@staticmethod
def api_key():
return get_secret("AIRTABLE_API_KEY")
"""

id: str = ""
Expand Down Expand Up @@ -122,6 +134,18 @@ def _field_name_attribute_map(cls) -> Dict[FieldName, str]:
return {v.field_name: k for k, v in cls._attribute_descriptor_map().items()}

def __init__(self, **fields: Any):
"""
Constructs a model instance with field values based on the given keyword args.

>>> Contact(name="Alice", birthday=date(1980, 1, 1))
<unsaved Contact>

The keyword argument ``id=`` special-cased and sets the record ID, not a field value.

>>> Contact(id="recWPqD9izdsNvlE", name="Bob")
<Contact id='recWPqD9izdsNvlE'>
"""

if "id" in fields:
self.id = fields.pop("id")

Expand Down Expand Up @@ -184,15 +208,19 @@ def _typecast(cls) -> bool:
return bool(cls._get_meta("typecast", default=True))

def exists(self) -> bool:
"""Returns boolean indicating if instance exists (has 'id' attribute)"""
"""
Whether the instance has been saved to Airtable already.
"""
return bool(self.id)

def save(self) -> bool:
"""
Saves or updates a model.
If instance has no 'id', it will be created, otherwise updated.

Returns ``True`` if was created and `False` if it was updated
If the instance does not exist already, it will be created;
otherwise, the existing record will be updated.

Returns ``True`` if a record was created and ``False`` if it was updated.
"""
if self._deleted:
raise RuntimeError(f"{self.id} was deleted")
Expand All @@ -211,7 +239,9 @@ def save(self) -> bool:
return did_create

def delete(self) -> bool:
"""Deletes record. Must have 'id' field"""
"""
Deletes the record. Raises ``ValueError`` if the record does not exist.
"""
if not self.id:
raise ValueError("cannot be deleted because it does not have id")
table = self.get_table()
Expand Down
20 changes: 14 additions & 6 deletions tests/test_orm_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,21 @@ class Address(Model):
street = f.TextField("Street")


def test_model_overlapping():
# Should raise error because conflicts with .exists()
@pytest.mark.parametrize("name", ("exists", "id"))
def test_model_overlapping(name):
"""
Test that we raise ValueError when a subclass of Model defines
a field with the same name as one of Model's properties or methods.
"""
with pytest.raises(ValueError):

class Address(Model):
Meta = fake_meta()
exists = f.TextField("Exists") # clases with Model.exists()
type(
"Address",
(Model,),
{
"Meta": fake_meta(),
name: f.TextField(name),
},
)


class FakeModel(Model):
Expand Down