diff --git a/.github/workflows/integration_test.yml b/.github/workflows/integration_test.yml index 836b0db..31d8128 100644 --- a/.github/workflows/integration_test.yml +++ b/.github/workflows/integration_test.yml @@ -1,7 +1,7 @@ name: Integration Test on: - pull_request_review: - types: [submitted] + pull_request: + types: [opened, synchronize] env: INTEGRATION_TEST_AUTH_JSON: ${{ secrets.INTEGRATION_TEST_AUTH_JSON }} @@ -10,6 +10,7 @@ jobs: integration_test: runs-on: ubuntu-latest environment: integration_test + if: github.event.review.state == 'approved' || github.event.pull_request.user.login == 'edocsss' strategy: matrix: python-version: ["3.10"] @@ -24,4 +25,3 @@ jobs: cache: "pip" - run: ./scripts/dev.sh - run: ./scripts/integration_test.sh - if: github.event.review.state == 'approved' diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index 5776031..bf2636b 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -6,7 +6,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.7", "3.10"] + python-version: ["3.10", "3.11", "3.12"] name: Python ${{ matrix.python-version }} steps: - uses: actions/checkout@v3 diff --git a/pyproject.toml b/pyproject.toml index 4ae9dd6..a78c041 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,11 +25,11 @@ Home = "https://github.com/FreeLeh/PyFreeDB" [project.optional-dependencies] test = [ - "black==22.3.0", + "black==24.10.0", "mypy==0.961", "isort==5.10.1", "pytest==7.1.2", - "autoflake==1.4", + "autoflake==2.3.1", "types-requests==2.28.6", "coverage==6.4.4", ] @@ -43,7 +43,7 @@ line_length = 120 [tool.black] line-length = 120 -target-version = ['py37', 'py38'] +target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312'] include = '\.pyi?$' [tool.mypy] diff --git a/requirements.txt b/requirements.txt index b780428..d906f18 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,10 +2,10 @@ google-api-python-client==2.51.0 google-auth-httplib2==0.1.0 google-auth-oauthlib==0.5.2 requests>=2.30, <3 -black>=24.3.0 +black>=24.10.0 mypy==0.961 isort==5.10.1 pytest==7.1.2 -autoflake==1.4 +autoflake==2.3.1 types-requests==2.28.6 coverage==6.4.4 diff --git a/src/pyfreedb/__init__.py b/src/pyfreedb/__init__.py index 8468e29..93cc7b5 100644 --- a/src/pyfreedb/__init__.py +++ b/src/pyfreedb/__init__.py @@ -1,3 +1,3 @@ """PyFreeDB is a Python library that provides common and simple database abstractions on top of Google Sheets.""" -__version__ = "1.0.3" +__version__ = "1.0.4" diff --git a/src/pyfreedb/row/models.py b/src/pyfreedb/row/models.py index dca7025..25147bd 100644 --- a/src/pyfreedb/row/models.py +++ b/src/pyfreedb/row/models.py @@ -20,13 +20,25 @@ class _Field(Generic[T]): _typ: Type[T] _column_name: str _field_name: str - - def __init__(self, column_name: str = "") -> None: + _is_formula: bool + + def __init__( + self, + column_name: str = "", + is_formula: bool = False, + ) -> None: + """Defines the internal representation of the model fields. + This is where we can put per field custom config as well. + + Args: + column_name: an alias of the field name to represent the actual column name in the sheets. + is_formula: a boolean indicating if the field is a formula or not. Only applicable for StringField. + """ self._column_name = column_name + self._is_formula = is_formula def __set_name__(self, _: Any, name: str) -> None: self._field_name = name - if self._column_name == "": self._column_name = name @@ -41,8 +53,13 @@ def __set__(self, obj: Any, value: Optional[T]) -> None: # as float by Google Sheet's API. value = self._typ(value) # type: ignore [call-arg] + self._ensure_is_formula() return setattr(obj._data, self._field_name, value) + def _ensure_is_formula(self) -> None: + if self._is_formula and self._typ is not str: + raise TypeError(f"Field {self._field_name} must be a StringField when is_formula is true") + def _ensure_type(self, value: Any) -> None: if value is None or value is NotSet: return diff --git a/src/pyfreedb/row/stmt.py b/src/pyfreedb/row/stmt.py index aee42e4..4e4a3d4 100644 --- a/src/pyfreedb/row/stmt.py +++ b/src/pyfreedb/row/stmt.py @@ -183,8 +183,10 @@ def _get_raw_values(self) -> List[List[str]]: # Set _rid value according to the insert protocol. raw = ["=ROW()"] - for field_name in row._fields: - value = _escape_val(getattr(row, field_name)) + for field_name, field in row._fields.items(): + field_is_formula = field._is_formula + raw_value = getattr(row, field_name) + value = raw_value if field_is_formula else _escape_val(raw_value) raw.append(value) raw_values.append(raw) @@ -246,7 +248,8 @@ def _update_rows(self, indices: List[int]) -> None: if col not in self._update_values: continue - value = _escape_val(self._update_values[col]) + field_is_formula = self._store._object_cls._fields[col]._is_formula + value = self._update_values[col] if field_is_formula else _escape_val(self._update_values[col]) cell_selector = _A1CellSelector.from_rc(col_idx + 2, row_idx) update_range = _A1Range(self._store._sheet_name, cell_selector, cell_selector) requests.append(_BatchUpdateRowsRequest(update_range, [[value]])) @@ -320,9 +323,10 @@ def _escape_val(val: Any) -> Any: return val -__pdoc__ = {} -__pdoc__["CountStmt"] = CountStmt.__init__.__doc__ -__pdoc__["SelectStmt"] = SelectStmt.__init__.__doc__ -__pdoc__["InsertStmt"] = InsertStmt.__init__.__doc__ -__pdoc__["DeleteStmt"] = DeleteStmt.__init__.__doc__ -__pdoc__["UpdateStmt"] = UpdateStmt.__init__.__doc__ +__pdoc__ = { + "CountStmt": CountStmt.__init__.__doc__, + "SelectStmt": SelectStmt.__init__.__doc__, + "InsertStmt": InsertStmt.__init__.__doc__, + "DeleteStmt": DeleteStmt.__init__.__doc__, + "UpdateStmt": UpdateStmt.__init__.__doc__, +} diff --git a/tests/integration/test_gsheet_row_store.py b/tests/integration/test_gsheet_row_store.py index 53b6b96..e241d1e 100644 --- a/tests/integration/test_gsheet_row_store.py +++ b/tests/integration/test_gsheet_row_store.py @@ -118,3 +118,40 @@ def test_gsheet_row_number_boundaries(config: IntegrationTestConfig) -> None: returned_rows = row_store.select().limit(1).execute() assert [expected_rows[1]] == returned_rows + + +class InsertModel(models.Model): + value = models.StringField(is_formula=True) + + +class ReadModel(models.Model): + value = models.IntegerField() + + +@pytest.mark.integration +def test_gsheet_row_formula(config: IntegrationTestConfig) -> None: + insert_store = GoogleSheetRowStore( + config.auth_client, + spreadsheet_id=config.spreadsheet_id, + sheet_name="row_store_formula", + object_cls=InsertModel, + ) + read_store = GoogleSheetRowStore( + config.auth_client, + spreadsheet_id=config.spreadsheet_id, + sheet_name="row_store_formula", + object_cls=ReadModel, + ) + + rows = [InsertModel(value="=ROW()-1")] + insert_store.insert(rows).execute() + + expected_rows = [ReadModel(value=1)] + returned_rows = read_store.select().execute() + assert expected_rows == returned_rows + + insert_store.update({"value": "=ROW()"}).execute() + + expected_rows = [ReadModel(value=2)] + returned_rows = read_store.select().execute() + assert expected_rows == returned_rows diff --git a/tests/row/test_models.py b/tests/row/test_models.py index 3ab63af..3f6c3a4 100644 --- a/tests/row/test_models.py +++ b/tests/row/test_models.py @@ -89,3 +89,14 @@ def test_is_ieee754_safe_integer() -> None: assert not models._is_ieee754_safe_integer(9007199254740993) assert models._is_ieee754_safe_integer(1 << 54) + + +class FormulaTest(models.Model): + string_no_formula = models.StringField(is_formula=False) + string_with_formula = models.StringField(is_formula=True) + + +def test_field_is_formula() -> None: + f = FormulaTest(string_no_formula="", string_with_formula="") + assert not f._fields["string_no_formula"]._is_formula + assert f._fields["string_with_formula"]._is_formula