Skip to content

Commit

Permalink
Merge pull request #285 from mesozoic/release_2_0
Browse files Browse the repository at this point in the history
Release 2.0
  • Loading branch information
mesozoic authored Jul 31, 2023
2 parents 8fea65a + 6083f71 commit f203ac9
Show file tree
Hide file tree
Showing 10 changed files with 171 additions and 46 deletions.
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

0 comments on commit f203ac9

Please sign in to comment.