diff --git a/README.md b/README.md index 0bc524f0..d5dd801c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 376781c8..dd109ecd 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -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. diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst new file mode 100644 index 00000000..27caa78e --- /dev/null +++ b/docs/source/contributing.rst @@ -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 `_. 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 `_ 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 `_ 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 `_ 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 `_. 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! diff --git a/docs/source/index.rst b/docs/source/index.rst index cfcdd1ec..1f4cd764 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -36,6 +36,7 @@ pyAirtable about changelog - Airtable API Docs + contributing GitHub PyPI + Airtable API diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst index d58c6d09..d95e113d 100644 --- a/docs/source/migrations.rst +++ b/docs/source/migrations.rst @@ -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 ` + * - ``table.table_name`` + - :data:`table.name ` + * - ``table.get_base()`` + - :data:`table.base ` + * - ``table.base_id`` + - :data:`table.base.id ` + * - ``table.table_url`` + - :meth:`table.url ` + * - ``table.get_record_url()`` + - :meth:`table.record_url() ` + +There is no fully exhaustive list of changes; please refer to +:ref:`the API documentation ` 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 @@ -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 ` before submitting an issue. + + +------ + + Migrating from 0.x to 1.0 ============================ @@ -90,6 +123,7 @@ The last ``0.x`` release will remain available on `PyPI `__. + New Features in 1.0 ------------------- diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py index 1da46784..bf32531c 100644 --- a/pyairtable/__init__.py +++ b/pyairtable/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.0.0rc1" +__version__ = "2.0.0" from .api import Api, Base, Table from .api.retrying import retry_strategy diff --git a/pyairtable/api/base.py b/pyairtable/api/base.py index 2d2d2b12..460c0e42 100644 --- a/pyairtable/api/base.py +++ b/pyairtable/api/base.py @@ -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): diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py index 94b07d62..386b71e4 100644 --- a/pyairtable/api/table.py +++ b/pyairtable/api/table.py @@ -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 @@ -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 @@ -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. diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py index f285daf1..7a38ebde 100644 --- a/pyairtable/orm/model.py +++ b/pyairtable/orm/model.py @@ -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 = "" @@ -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)) + + + The keyword argument ``id=`` special-cased and sets the record ID, not a field value. + + >>> Contact(id="recWPqD9izdsNvlE", name="Bob") + + """ + if "id" in fields: self.id = fields.pop("id") @@ -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") @@ -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() diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py index 832a7cbf..a9d3e38c 100644 --- a/tests/test_orm_model.py +++ b/tests/test_orm_model.py @@ -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):