diff --git a/.github/scripts/make_ee_token.py b/.github/scripts/make_ee_token.py index b02d8bb..6535165 100644 --- a/.github/scripts/make_ee_token.py +++ b/.github/scripts/make_ee_token.py @@ -6,4 +6,4 @@ os.makedirs(credential_dir, exist_ok=True) with open(os.path.join(credential_dir, "credentials"), "w") as dst: - dst.write(credentials) \ No newline at end of file + dst.write(credentials) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e6a7cf4..f5227d9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,37 +1,40 @@ -# This workflow sets up the Earth Engine token and runs all tests. +name: Test and lint -name: tests +on: [push, pull_request] -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] +permissions: + contents: read jobs: - build: - + test: runs-on: ubuntu-latest strategy: matrix: - python-version: [ '3.7', '3.8', '3.9', '3.10' ] + python-version: [ '3.8', '3.9', '3.10', '3.11' ] steps: - - uses: actions/checkout@v2 + - name: Checkout + uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: ${{ matrix.python-version }} + - name: Install dependencies run: | python -m pip install --upgrade pip - pip install pytest - pip install -e .[test] + pip install hatch + - name: Store EE token run: | python ./.github/scripts/make_ee_token.py env: EE_TOKEN: ${{ secrets.EE_TOKEN }} - - name: Test with pytest + + - name: Test run: | - pytest . \ No newline at end of file + hatch run test:all + + - name: Pre-commit + uses: pre-commit/action@v3.0.0 \ No newline at end of file diff --git a/.gitignore b/.gitignore index f794d2f..2fdc256 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ __pycache__/ htmlcov/ dist/ *.egg-info/ -.tox/ \ No newline at end of file +.tox/ + +tests/data/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1f33bb5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.0.278 + hooks: + - id: ruff + + - repo: https://github.com/psf/black + rev: 22.10.0 + hooks: + - id: black + args: [--line-length=88, --preview] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.2.0 + hooks: + - id: mypy + exclude: ^tests/|^setup.py + args: [--ignore-missing-imports] \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b3aa822 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,80 @@ +# Contributing to eeprepr + +Contributions are always welcome! Bugs and feature requests can be opened in the [Issues](https://github.com/aazuspan/eerepr/issues). Questions and comments can be posted in the [Discussions](https://github.com/aazuspan/eerepr/discussions). To contribute code, please open an issue to discuss implementation, then follow the guide below to get started! + +## Setup + +`eeprepr` uses [Hatch](https://hatch.pypa.io/latest/) for package and environment management. To set up a development environment, first fork and clone `eerepr`, then install `hatch` in your environment. + +```bash +pip install hatch +``` + +This will install all required dependencies for development. You can enter the environment using: + +```bash +hatch shell +``` + +and exit by typing `quit` or `CTRL + D`. + +## Pre-commit Hooks + +Pre-commit hooks automatically run linting, formatting, and type-checking whenever a change is commited. This ensures that the codebase is always in good shape. + +The command below registers the pre-commit hooks for the project so that they run before every commit. + +```bash +hatch run pre-commit install +``` + +To run all the checks manually, you can use: + +```bash +hatch run pre-commit run --all-files +``` + +## Testing + +### Running Tests + +You can run all tests with `pytest` using the command below: + +```bash +hatch run test:all +``` + +To measure test coverage, run: + +```bash +hatch run test:cov +``` + +Additional arguments can be passed to `pytest` after the script name, e.g.: + +```bash +hatch run test:all -k feature +``` + +### Building New Tests + +New features should have unit tests. If your test needs to use `getInfo` to retrieve data from an Earth Engine object, you'll need to use the caching system described below. + +Using `getInfo` to retrieve data from an Earth Engine object can be slow and network-dependent. To speed up tests, `eerepr` uses a caching function `tests.cache.get_info` to load data. This function takes an Earth Engine object and either 1) retrieves its info from a local cache file if it has been used before, or 2) retrieves it from the server and adds it to the cache. The cache directory and file (`tests/data/data.json`) will be created automatically the first time tests are run. + +To demonstrate, let's write a new dummy test that checks the properties of a custom `ee.Image`. + +```python +from tests.cache import get_info + +def test_my_image(): + img = ee.Image.constant(42).set("custom_property", ["a", "b", "c"]) + # Use `get_info` instead of `img.getInfo` to utilize the cache + info = get_info(img) + + assert "custom_property" in info["properties"] +``` + +The first time the test is run, `getInfo` will be used to retrieve the image metadata and store it in `tests/data/data.json`. Subsequent runs will pull the data directly from the cache. + +Caches are kept locally and are not version-controlled, so there's no need to commit newly added objects. \ No newline at end of file diff --git a/eerepr/__init__.py b/eerepr/__init__.py index 1dbca12..fa92573 100644 --- a/eerepr/__init__.py +++ b/eerepr/__init__.py @@ -1,5 +1,3 @@ -import ee - from eerepr.config import options from eerepr.repr import initialize diff --git a/eerepr/config.py b/eerepr/config.py index 9a18ee7..255e39b 100644 --- a/eerepr/config.py +++ b/eerepr/config.py @@ -1,8 +1,12 @@ +from __future__ import annotations + import json class Config: - def __init__(self, max_cache_size, max_repr_mbs, communication_delay): + def __init__( + self, max_cache_size: int | None, max_repr_mbs: int, communication_delay: float + ): self.max_cache_size = max_cache_size self.max_repr_mbs = max_repr_mbs self.communication_delay = communication_delay @@ -16,6 +20,7 @@ def __repr__(self): max_cache_size=None, # Max size of repr content in MB to prevent performance issues max_repr_mbs=100, - # Minimum delay in seconds before updating widgets to prevent communication timing issues. + # Minimum delay in seconds before updating widgets to prevent communication timing + # issues. Delayed that are too low can break collapsing behavior. communication_delay=0.1, ) diff --git a/eerepr/html.py b/eerepr/html.py index 09ffba3..5cd34ea 100644 --- a/eerepr/html.py +++ b/eerepr/html.py @@ -1,10 +1,13 @@ +from __future__ import annotations + import datetime -from itertools import chain -from typing import Any from html import escape +from itertools import chain +from typing import Any, Hashable + import ee -# Max number of characters to display for a list before truncating to "List (n elements)"" +# Max characters to display for a list before truncating to "List (n elements)" MAX_INLINE_LENGTH = 50 # Sorting priority for Earth Engine properties PROPERTY_PRIORITY = [ @@ -17,21 +20,28 @@ "properties", ] + def build_loading_html(obj: Any) -> str: """Build an HTML element to display when asynchronously loading an object.""" spinner = """
""" - return f"{spinner} {obj.__class__.__name__} (Computing)" + return ( + "{spinner} {obj.__class__.__name__} (Computing)" + ) def build_error_html(err: ee.EEException) -> str: """Build an HTML element to display an Earth Engine Exception""" return f"
{escape(str(err))}
" + def build_fallback_html(obj: Any) -> str: - """Build an HTML element to fall back to if something goes wrong in the main repr.""" + """Build an HTML element to fall back to if something goes wrong in the main repr. + """ return f"
{escape(repr(obj))}
" -def build_object_html(obj: Any, key=None) -> str: + +def build_object_html(obj: Any, key: Hashable | None = None) -> str: """Build an HTML
  • element from a Python object. Parameters @@ -39,23 +49,19 @@ def build_object_html(obj: Any, key=None) -> str: obj : Any The object to convert to HTML. key : str, optional - The key to prepend to the object value, in the case of a dictionary value or list element. + The key to prepend to the object value, in the case of a dictionary value or + list element. """ if isinstance(obj, list): return list_to_html(obj, key) - elif isinstance(obj, dict): + if isinstance(obj, dict): return dict_to_html(obj, key) key_html = f"{key}:" if key is not None else "" - return ( - "
  • " - f"{key_html}" - f"{obj}" - "
  • " - ) + return f"
  • {key_html}{obj}
  • " -def list_to_html(obj: list, key=None) -> str: +def list_to_html(obj: list, key: Hashable | None = None) -> str: """Convert a Python list to an HTML
  • element.""" contents = str(obj) n = len(obj) @@ -67,7 +73,7 @@ def list_to_html(obj: list, key=None) -> str: return _make_collapsible_li(header, children) -def dict_to_html(obj: dict, key=None) -> str: +def dict_to_html(obj: dict, key: Hashable | None = None) -> str: """Convert a Python dictionary to an HTML
  • element.""" obj = _sort_dict(obj) label = _build_label(obj) @@ -82,8 +88,8 @@ def dict_to_html(obj: dict, key=None) -> str: def _sort_dict(obj: dict) -> dict: """Sort the properties of an Earth Engine object. - This follows the Code Editor standard where priority keys are sorted first and the rest are - returned in alphabetical order. + This follows the Code Editor standard where priority keys are sorted first and the + rest are returned in alphabetical order. """ priority_keys = [k for k in PROPERTY_PRIORITY if k in obj] start = {k: obj[k] for k in priority_keys} @@ -91,12 +97,12 @@ def _sort_dict(obj: dict) -> dict: return {**start, **end} -def _make_collapsible_li(header, children) -> str: - """Package a header and children into a collapsible list element""" +def _make_collapsible_li(header: str, children: list) -> str: + """Package a header and children into a collapsible list element.""" return ( "
  • " f"" + "" f"" "
  • " ) diff --git a/eerepr/repr.py b/eerepr/repr.py index a5d5733..ffcdcf2 100644 --- a/eerepr/repr.py +++ b/eerepr/repr.py @@ -1,29 +1,40 @@ +from __future__ import annotations + +import threading +import time import uuid from functools import _lru_cache_wrapper, lru_cache from typing import Callable, Type, Union -import threading -import anywidget -import traitlets -import time from warnings import warn +import anywidget import ee +import traitlets from eerepr.config import options -from eerepr.html import build_object_html, build_loading_html, build_error_html, build_fallback_html +from eerepr.html import ( + build_error_html, + build_fallback_html, + build_loading_html, + build_object_html, +) from eerepr.utils import is_nondeterministic, load_css, load_js +REPR_HTML = "_repr_html_" +EEObject = Union[ee.Element, ee.ComputedObject] +EEClass = Type[Union[ee.Element, ee.ComputedObject]] -# Track which reprs have been set so we can overwrite them if needed. +# Track which html reprs have been set so we can overwrite them if needed. reprs_set = set() -EEObject = Union[ee.Element, ee.ComputedObject] -def _attach_repr(cls: Type, repr: Callable) -> None: - """Add a custom repr method to an EE class. Only overwrite the method if it was set by this function.""" +def _attach_repr(cls: EEClass, repr: Callable) -> None: + """Add a custom repr method to an EE class. Only overwrite the method if it was set + by this function. + """ if not hasattr(cls, "_ipython_display_") or cls.__name__ in reprs_set: reprs_set.update([cls.__name__]) - setattr(cls, "_ipython_display_", repr) + cls._ipython_display_ = repr def _ipython_display_(obj: EEObject, **kwargs) -> str: @@ -35,8 +46,8 @@ def _ipython_display_(obj: EEObject, **kwargs) -> str: def _get_cached_repr(obj: EEObject) -> str: """Build or retrieve an HTML repr from an Earth Engine object.""" if is_nondeterministic(obj): - # Prevent caching of non-deterministic objects (e.g. ee.List([]).shuffle(False))) - setattr(obj, "_eerepr_id", uuid.uuid4()) + # Prevent caching non-deterministic objects (e.g. ee.List([]).shuffle(False))) + obj._eerepr_id = uuid.uuid4() try: info = obj.getInfo() @@ -47,21 +58,21 @@ def _get_cached_repr(obj: EEObject) -> str: return content -def initialize(max_cache_size=None) -> None: - """Attach repr methods to EE objects and set the cache size. +def initialize(max_cache_size: int | None = None) -> None: + """Attach HTML repr methods to EE objects and set the cache size. Re-running this function will reset the cache. Parameters ---------- max_cache_size : int, optional - The maximum number of EE objects to cache. If None, the cache size is unlimited. Set to 0 - to disable caching. + The maximum number of EE objects to cache. If None, the cache size is unlimited. + Set to 0 to disable caching. """ global _get_cached_repr # Unwrap from the LRU cache so we can reset it if isinstance(_get_cached_repr, _lru_cache_wrapper): - _get_cached_repr = _get_cached_repr.__wrapped__ + _get_cached_repr = _get_cached_repr.__wrapped__ # type: ignore # If caching is enabled, rewrap in a new LRU cache if max_cache_size != 0: @@ -74,7 +85,7 @@ def initialize(max_cache_size=None) -> None: class EEReprWidget(anywidget.AnyWidget): _esm = load_js() _css = load_css() - + content = traitlets.Unicode().tag(sync=True) def __init__(self, obj: EEObject, *args, **kwargs): @@ -98,15 +109,19 @@ def update_content(self) -> None: self.set_content(rep) def set_content(self, content: str) -> None: - """Set the widget content, checking content size to avoid crashes from huge reprs.""" + """Set the widget content, checking content size to avoid crashes from huge + reprs. + """ mbs = len(content) / 1e6 if mbs > options.max_repr_mbs: warn( message=( - f"HTML repr size ({mbs:.0f}mB) exceeds maximum ({options.max_repr_mbs:.0f}mB), falling" - " back to string repr. You can set `eerepr.options.max_repr_mbs` to display larger" - " objects, but this may cause performance issues." - ) + f"HTML repr size ({mbs:.0f}mB) exceeds maximum" + f" ({options.max_repr_mbs:.0f}mB), falling back to string repr. You" + " can set `eerepr.options.max_repr_mbs` to display larger objects," + " but this may cause performance issues." + ), + stacklevel=2, ) content = build_fallback_html(self.obj) @@ -125,8 +140,9 @@ def _ipython_display_(self, **kwargs): # ipywidgets v7 super()._ipython_display_(**kwargs) else: - from IPython.display import display import ipywidgets + from IPython.display import display + # ipywidgets v8 data = ipywidgets.DOMWidget._repr_mimebundle_(self, **kwargs) display(data, raw=True) diff --git a/eerepr/utils.py b/eerepr/utils.py index c35c106..5cf145e 100644 --- a/eerepr/utils.py +++ b/eerepr/utils.py @@ -1,5 +1,5 @@ -from importlib.resources import read_text from functools import lru_cache +from importlib.resources import read_text def is_nondeterministic(obj): @@ -17,6 +17,7 @@ def is_nondeterministic(obj): def load_css(): return read_text("eerepr.static.css", "style.css") + @lru_cache(maxsize=None) def load_js(): - return read_text("eerepr.static.js", "widget.js") \ No newline at end of file + return read_text("eerepr.static.js", "widget.js") diff --git a/pyproject.toml b/pyproject.toml index bd31410..a020fd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description = "Code Editor-style reprs for Earth Engine data in a Jupyter notebook." readme = "README.md" license = { file = "LICENSE" } -requires-python = ">=3.7" +requires-python = ">=3.8" authors = [{ name = "Aaron Zuspan" }] keywords = [ "earthengine", @@ -25,17 +25,6 @@ dependencies = [ "traitlets", ] -[project.optional-dependencies] -dev = [ - "black", - "ipykernel", - "isort", - "jupyterlab", - "pytest", - "pytest-cov", - "hatch", -] - [project.urls] Homepage = "https://github.com/aazuspan/eerepr" @@ -45,29 +34,20 @@ path = "eerepr/__init__.py" [tool.hatch.build.targets.sdist] include = ["/eerepr"] +[tool.hatch.envs.default] +dependencies = ["pre-commit"] + [tool.hatch.envs.test] dependencies = [ - "earthengine-api", "pytest", "pytest-cov", - "tox", ] [tool.hatch.envs.test.scripts] -tests = "pytest ." -cov = "pytest . --cov=eerepr --cov-report=html" -view-cov = "open htmlcov/index.html" -ci = "tox" +all = "pytest . {args}" +cov = "pytest . --cov=eerepr {args}" -[tool.tox] -legacy_tox_ini = """ -[tox] -envlist = python3.7,python3.8,python3.9,python3.10 -[testenv] -deps = - pytest - pytest-cov - earthengine-api -commands = pytest -isolated_build = True -""" +[tool.ruff] +select = ["E", "I", "F", "B", "FA", "UP", "ISC", "PT", "Q", "RET", "SIM", "PERF"] +fix = true +show-fixes = true diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 7423ce8..0000000 --- a/setup.cfg +++ /dev/null @@ -1,15 +0,0 @@ -[metadata] -name = eerepr - -[options] -packages = find: -include_package_data = True - -[options.package_data] -eerepr = - static/css/* - static/js/* - -[options.packages.find] -exclude = - tests \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 6068493..0000000 --- a/setup.py +++ /dev/null @@ -1,3 +0,0 @@ -from setuptools import setup - -setup() diff --git a/tests/cache.py b/tests/cache.py new file mode 100644 index 0000000..3f8d9fb --- /dev/null +++ b/tests/cache.py @@ -0,0 +1,36 @@ +import json +import os + +CACHE_DIR = "./tests/data/" +CACHE_PATH = CACHE_DIR + "data.json" + + +def get_info(obj): + """Load client-side info for an Earth Engine object. + + Info is retrieved (if available) from a local JSON file using the serialized + object as the key. If the data does not exist locally, it is loaded from Earth + Engine servers and stored for future use. + """ + serialized = obj.serialize() + + if not os.path.isdir(CACHE_DIR): + os.mkdir(CACHE_DIR) + + try: + with open(CACHE_PATH) as src: + existing_data = json.load(src) + + # File is missing or unreadable + except (FileNotFoundError, json.JSONDecodeError): + existing_data = {} + with open(CACHE_PATH, "w") as dst: + json.dump(existing_data, dst) + + # File exists, but info does not + if serialized not in existing_data: + with open(CACHE_PATH, "w") as dst: + existing_data[serialized] = obj.getInfo() + json.dump(existing_data, dst, indent=2) + + return existing_data[serialized] diff --git a/tests/data/data.json b/tests/data/data.json deleted file mode 100644 index 91e5c5e..0000000 --- a/tests/data/data.json +++ /dev/null @@ -1 +0,0 @@ -{"{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"Element.set\", \"arguments\": {\"key\": {\"constantValue\": \"system:id\"}, \"object\": {\"functionInvocationValue\": {\"functionName\": \"Image.constant\", \"arguments\": {\"value\": {\"constantValue\": 0}}}}, \"value\": {\"constantValue\": \"foo\"}}}}}}": {"type": "Image", "bands": [{"id": "constant", "data_type": {"type": "PixelType", "precision": "int", "min": 0, "max": 0}, "crs": "EPSG:4326", "crs_transform": [1, 0, 0, 0, 1, 0]}], "id": "foo"}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"Element.set\", \"arguments\": {\"key\": {\"constantValue\": \"test_prop\"}, \"object\": {\"functionInvocationValue\": {\"functionName\": \"ImageCollection.fromImages\", \"arguments\": {\"images\": {\"arrayValue\": {\"values\": [{\"functionInvocationValue\": {\"functionName\": \"Image.constant\", \"arguments\": {\"value\": {\"constantValue\": 0}}}}, {\"functionInvocationValue\": {\"functionName\": \"Image.constant\", \"arguments\": {\"value\": {\"constantValue\": 1}}}}]}}}}}, \"value\": {\"constantValue\": 42}}}}}}": {"type": "ImageCollection", "bands": [], "properties": {"test_prop": 42}, "features": [{"type": "Image", "bands": [{"id": "constant", "data_type": {"type": "PixelType", "precision": "int", "min": 0, "max": 0}, "crs": "EPSG:4326", "crs_transform": [1, 0, 0, 0, 1, 0]}], "properties": {"system:index": "0"}}, {"type": "Image", "bands": [{"id": "constant", "data_type": {"type": "PixelType", "precision": "int", "min": 1, "max": 1}, "crs": "EPSG:4326", "crs_transform": [1, 0, 0, 0, 1, 0]}], "properties": {"system:index": "1"}}]}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"Feature\", \"arguments\": {\"geometry\": {\"functionInvocationValue\": {\"functionName\": \"GeometryConstructors.Point\", \"arguments\": {\"coordinates\": {\"constantValue\": [0, 0]}}}}, \"metadata\": {\"constantValue\": {\"foo\": \"bar\"}}}}}}}": {"type": "Feature", "geometry": {"type": "Point", "coordinates": [0, 0]}, "properties": {"foo": "bar"}}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"Feature\", \"arguments\": {}}}}}": {"type": "Feature", "geometry": null, "properties": {}}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"Collection\", \"arguments\": {\"features\": {\"arrayValue\": {\"values\": [{\"functionInvocationValue\": {\"functionName\": \"Feature\", \"arguments\": {\"geometry\": {\"functionInvocationValue\": {\"functionName\": \"GeometryConstructors.Point\", \"arguments\": {\"coordinates\": {\"constantValue\": [0, 0]}}}}, \"metadata\": {\"constantValue\": {\"foo\": \"bar\"}}}}}]}}}}}}}": {"type": "FeatureCollection", "columns": {"foo": "String", "system:index": "String"}, "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [0, 0]}, "id": "0", "properties": {"foo": "bar"}}]}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"Date\", \"arguments\": {\"value\": {\"constantValue\": \"2021-03-27T14:01:07\"}}}}}}": {"type": "Date", "value": 1616853667000}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"Filter.equals\", \"arguments\": {\"leftField\": {\"constantValue\": \"foo\"}, \"rightValue\": {\"constantValue\": \"bar\"}}}}}}": {"type": "Filter.eq", "rightValue": "bar", "leftField": "foo"}, "{\"result\": \"0\", \"values\": {\"0\": {\"constantValue\": {\"foo\": \"bar\"}}}}": {"foo": "bar"}, "{\"result\": \"0\", \"values\": {\"0\": {\"constantValue\": [1, 2, 3, 4]}}}": [1, 2, 3, 4], "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"List.sequence\", \"arguments\": {\"end\": {\"constantValue\": 20}, \"start\": {\"constantValue\": 0}, \"step\": {\"constantValue\": 1}}}}}}": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], "{\"result\": \"0\", \"values\": {\"0\": {\"constantValue\": \"13th Warrior is an underrated movie\"}}}": "13th Warrior is an underrated movie", "{\"result\": \"0\", \"values\": {\"0\": {\"constantValue\": 42}}}": 42, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"GeometryConstructors.Point\", \"arguments\": {\"coordinates\": {\"constantValue\": [1.112312, 2]}}}}}}": {"type": "Point", "coordinates": [1.112312, 2]}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"GeometryConstructors.MultiPoint\", \"arguments\": {\"coordinates\": {\"constantValue\": [[1, 1], [2, 2]]}}}}}}": {"type": "MultiPoint", "coordinates": [[1, 1], [2, 2]]}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"GeometryConstructors.LineString\", \"arguments\": {\"coordinates\": {\"constantValue\": [[1, 1], [2, 2], [3, 3]]}}}}}}": {"type": "LineString", "coordinates": [[1, 1], [2, 2], [3, 3]]}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"GeometryConstructors.MultiLineString\", \"arguments\": {\"coordinates\": {\"constantValue\": [[[0, 0], [1, 1]]]}}}}}}": {"type": "MultiLineString", "coordinates": [[[0, 0], [1, 1]]]}, "{\"result\": \"0\", \"values\": {\"1\": {\"constantValue\": [0, 0]}, \"0\": {\"functionInvocationValue\": {\"functionName\": \"GeometryConstructors.Polygon\", \"arguments\": {\"coordinates\": {\"arrayValue\": {\"values\": [{\"arrayValue\": {\"values\": [{\"valueReference\": \"1\"}, {\"constantValue\": [1, 1]}, {\"constantValue\": [2, 2]}, {\"valueReference\": \"1\"}]}}]}}, \"evenOdd\": {\"constantValue\": true}}}}}}": {"type": "Polygon", "coordinates": [[[0, 0], [1, 1], [2, 2], [0, 0]]]}, "{\"result\": \"0\", \"values\": {\"1\": {\"constantValue\": [0, 0]}, \"2\": {\"constantValue\": [4, 6]}, \"0\": {\"functionInvocationValue\": {\"functionName\": \"GeometryConstructors.MultiPolygon\", \"arguments\": {\"coordinates\": {\"arrayValue\": {\"values\": [{\"arrayValue\": {\"values\": [{\"arrayValue\": {\"values\": [{\"valueReference\": \"1\"}, {\"constantValue\": [1, 1]}, {\"constantValue\": [2, 2]}, {\"valueReference\": \"1\"}]}}, {\"arrayValue\": {\"values\": [{\"valueReference\": \"2\"}, {\"constantValue\": [3, 2]}, {\"constantValue\": [1, 2]}, {\"valueReference\": \"2\"}]}}]}}]}}, \"evenOdd\": {\"constantValue\": true}}}}}}": {"type": "MultiPolygon", "coordinates": [[[[0, 0], [1, 1], [2, 2], [0, 0]], [[4, 6], [3, 2], [1, 2], [4, 6]]]]}, "{\"result\": \"0\", \"values\": {\"1\": {\"constantValue\": [0, 0]}, \"0\": {\"functionInvocationValue\": {\"functionName\": \"GeometryConstructors.LinearRing\", \"arguments\": {\"coordinates\": {\"arrayValue\": {\"values\": [{\"valueReference\": \"1\"}, {\"constantValue\": [1, 1]}, {\"constantValue\": [2, 2]}, {\"valueReference\": \"1\"}]}}}}}}}": {"type": "LinearRing", "coordinates": [[0, 0], [1, 1], [2, 2], [0, 0]]}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"DateRange\", \"arguments\": {\"end\": {\"constantValue\": \"2022-03-01T14:32:11\"}, \"start\": {\"constantValue\": \"2020-01-01T21:01:10\"}}}}}}": {"type": "DateRange", "dates": [1577912470000, 1646145131000]}, "{\"result\": \"0\", \"values\": {\"0\": {\"constantValue\": {\"id\": \"bar\", \"type\": \"Foo\"}}}}": {"id": "bar", "type": "Foo"}, "{\"result\": \"0\", \"values\": {\"0\": {\"constantValue\": {\"crs\": \"EPSG:32610\", \"data_type\": {\"max\": 65535, \"min\": 0, \"precision\": \"int\", \"type\": \"PixelType\"}, \"dimensions\": [1830, 1830], \"id\": \"B1\"}}}}": {"crs": "EPSG:32610", "data_type": {"max": 65535, "min": 0, "precision": "int", "type": "PixelType"}, "dimensions": [1830, 1830], "id": "B1"}, "{\"result\": \"0\", \"values\": {\"0\": {\"constantValue\": {\"data_type\": {\"max\": 65535, \"min\": 0, \"precision\": \"int\", \"type\": \"PixelType\"}, \"id\": \"B1\"}}}}": {"data_type": {"max": 65535, "min": 0, "precision": "int", "type": "PixelType"}, "id": "B1"}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"PixelType\", \"arguments\": {\"precision\": {\"functionInvocationValue\": {\"functionName\": \"PixelType.float\", \"arguments\": {}}}}}}}}": {"type": "PixelType", "precision": "float"}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"PixelType\", \"arguments\": {\"precision\": {\"functionInvocationValue\": {\"functionName\": \"PixelType.double\", \"arguments\": {}}}}}}}}": {"type": "PixelType", "precision": "double"}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"PixelType\", \"arguments\": {\"precision\": {\"functionInvocationValue\": {\"functionName\": \"PixelType.int8\", \"arguments\": {}}}}}}}}": {"type": "PixelType", "precision": "int", "min": -128, "max": 127}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"PixelType\", \"arguments\": {\"precision\": {\"functionInvocationValue\": {\"functionName\": \"PixelType.uint8\", \"arguments\": {}}}}}}}}": {"type": "PixelType", "precision": "int", "min": 0, "max": 255}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"PixelType\", \"arguments\": {\"precision\": {\"functionInvocationValue\": {\"functionName\": \"PixelType.int16\", \"arguments\": {}}}}}}}}": {"type": "PixelType", "precision": "int", "min": -32768, "max": 32767}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"PixelType\", \"arguments\": {\"precision\": {\"functionInvocationValue\": {\"functionName\": \"PixelType.uint16\", \"arguments\": {}}}}}}}}": {"type": "PixelType", "precision": "int", "min": 0, "max": 65535}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"PixelType\", \"arguments\": {\"precision\": {\"functionInvocationValue\": {\"functionName\": \"PixelType.int32\", \"arguments\": {}}}}}}}}": {"type": "PixelType", "precision": "int", "min": -2147483648, "max": 2147483647}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"PixelType\", \"arguments\": {\"precision\": {\"functionInvocationValue\": {\"functionName\": \"PixelType.uint32\", \"arguments\": {}}}}}}}}": {"type": "PixelType", "precision": "int", "min": 0, "max": 4294967295}, "{\"result\": \"0\", \"values\": {\"0\": {\"functionInvocationValue\": {\"functionName\": \"PixelType\", \"arguments\": {\"precision\": {\"functionInvocationValue\": {\"functionName\": \"PixelType.int64\", \"arguments\": {}}}}}}}}": {"type": "PixelType", "precision": "int", "min": -9.223372036854776e+18, "max": 9.223372036854776e+18}, "{\"result\": \"0\", \"values\": {\"0\": {\"constantValue\": {\"max\": 255, \"min\": 10, \"precision\": \"int\", \"type\": \"PixelType\"}}}}": {"max": 255, "min": 10, "precision": "int", "type": "PixelType"}} \ No newline at end of file diff --git a/tests/test_cache.py b/tests/test_cache.py index c2563e4..aadca39 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -3,23 +3,26 @@ import ee import eerepr +from eerepr.repr import _get_cached_repr def test_disabled_cache(): eerepr.initialize(max_cache_size=0) x = ee.Number(0) - assert not isinstance(x._repr_html_, _lru_cache_wrapper) + assert not isinstance(x._ipython_display_, _lru_cache_wrapper) def test_nondeterministic_caching(): - """ee.List.shuffle(seed=False) is nondeterministic. Make sure it misses the cache.""" + """ee.List.shuffle(seed=False) is nondeterministic. Make sure it misses the cache. + """ eerepr.initialize(max_cache_size=None) - cache = eerepr.repr._repr_html_ - cache.cache_clear() + _get_cached_repr.cache_clear() - assert cache.cache_info().misses == 0 + assert _get_cached_repr.cache_info().misses == 0 x = ee.List([0, 1, 2]).shuffle(seed=False) - x._repr_html_() - x._repr_html_() - assert cache.cache_info().misses == 2 + + _get_cached_repr(x) + _get_cached_repr(x) + + assert _get_cached_repr.cache_info().misses == 2 diff --git a/tests/test_html.py b/tests/test_html.py index 739aed5..64c6d70 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1,44 +1,12 @@ -import os -import json import ee -from eerepr.html import build_object_html - - -def load_info(obj): - """ - Load client-side info for an Earth Engine object. - - Info is retrieved (if available) from a local JSON file using the serialized object - as the key. If the data does not exist locally, it is loaded from Earth Engine servers - and stored for future use. - """ - serialized = obj.serialize() - - if not os.path.isdir("./tests/data"): - os.mkdir("./tests/data") - try: - with open("./tests/data/data.json", "r") as src: - existing_data = json.load(src) - - # File is missing or unreadable - except (FileNotFoundError, json.JSONDecodeError): - existing_data = {} - with open("./tests/data/data.json", "w") as dst: - json.dump(existing_data, dst) - - # File exists, but info does not - if serialized not in existing_data: - with open("./tests/data/data.json", "w") as dst: - existing_data[serialized] = obj.getInfo() - json.dump(existing_data, dst) - - return existing_data[serialized] +from eerepr.html import build_object_html +from tests.cache import get_info def test_image(): obj = ee.Image.constant(0).set("system:id", "foo") - info = load_info(obj) + info = get_info(obj) rep = build_object_html(info) assert "Image foo (1 band)" in rep assert "bands: List (1 element)" in rep @@ -46,16 +14,13 @@ def test_image(): def test_imagecollection(): - obj = ( - ee.ImageCollection( - [ - ee.Image.constant(0), - ee.Image.constant(1), - ] - ) - .set("test_prop", 42) - ) - info = load_info(obj) + obj = ee.ImageCollection( + [ + ee.Image.constant(0), + ee.Image.constant(1), + ] + ).set("test_prop", 42) + info = get_info(obj) rep = build_object_html(info) assert "ImageCollection (2 elements)" in rep @@ -64,25 +29,25 @@ def test_imagecollection(): def test_feature(): - obj = ee.Feature( - geom=ee.Geometry.Point([0, 0]), opt_properties={"foo": "bar"} - ) - info = load_info(obj) + obj = ee.Feature(geom=ee.Geometry.Point([0, 0]), opt_properties={"foo": "bar"}) + info = get_info(obj) rep = build_object_html(info) assert "Feature (Point, 1 property)" in rep + def test_empty_feature(): obj = ee.Feature(None) - info = load_info(obj) + info = get_info(obj) rep = build_object_html(info) - + assert "Feature (0 properties)" in rep + def test_featurecollection(): feat = ee.Feature(geom=ee.Geometry.Point([0, 0]), opt_properties={"foo": "bar"}) obj = ee.FeatureCollection([feat]) - info = load_info(obj) + info = get_info(obj) rep = build_object_html(info) assert "FeatureCollection (1 element, 2 columns)" in rep @@ -90,17 +55,16 @@ def test_featurecollection(): def test_date(): obj = ee.Date("2021-03-27T14:01:07") - info = load_info(obj) + info = get_info(obj) rep = build_object_html(info) assert "Date" in rep assert "2021-03-27 14:01:07" in rep - def test_filter(): obj = ee.Filter.eq("foo", "bar") - info = load_info(obj) + info = get_info(obj) rep = build_object_html(info) assert "Filter.eq" in rep @@ -108,7 +72,7 @@ def test_filter(): def test_dict(): obj = ee.Dictionary({"foo": "bar"}) - info = load_info(obj) + info = get_info(obj) rep = build_object_html(info) assert "Object (1 property)" in rep @@ -116,19 +80,19 @@ def test_dict(): def test_list(): short_obj = ee.List([1, 2, 3, 4]) - short_info = load_info(short_obj) + short_info = get_info(short_obj) short_rep = build_object_html(short_info) assert "[1, 2, 3, 4]" in short_rep long_obj = ee.List.sequence(0, 20, 1) - long_info = load_info(long_obj) + long_info = get_info(long_obj) long_rep = build_object_html(long_info) assert "List (21 elements)" in long_rep def test_string(): obj = ee.String("13th Warrior is an underrated movie") - info = load_info(obj) + info = get_info(obj) rep = build_object_html(info) assert "13th Warrior is an underrated movie" in rep @@ -136,7 +100,7 @@ def test_string(): def test_number(): obj = ee.Number(42) - info = load_info(obj) + info = get_info(obj) rep = build_object_html(info) assert "42" in rep @@ -144,7 +108,7 @@ def test_number(): def test_point(): obj = ee.Geometry.Point([1.112312, 2]) - info = load_info(obj) + info = get_info(obj) rep = build_object_html(info) assert "Point (1.11, 2.00)" in rep @@ -152,7 +116,7 @@ def test_point(): def test_multipoint(): obj = ee.Geometry.MultiPoint([[1, 1], [2, 2]]) - info = load_info(obj) + info = get_info(obj) rep = build_object_html(info) assert "MultiPoint (2 vertices)" in rep @@ -160,7 +124,7 @@ def test_multipoint(): def test_linestring(): obj = ee.Geometry.LineString([[1, 1], [2, 2], [3, 3]]) - info = load_info(obj) + info = get_info(obj) rep = build_object_html(info) assert "LineString (3 vertices)" in rep @@ -168,7 +132,7 @@ def test_linestring(): def test_multilinestring(): obj = ee.Geometry.MultiLineString([[[0, 0], [1, 1]]]) - info = load_info(obj) + info = get_info(obj) rep = build_object_html(info) assert "MultiLineString" in rep @@ -176,11 +140,12 @@ def test_multilinestring(): def test_polygon(): obj = ee.Geometry.Polygon([[0, 0], [1, 1], [2, 2], [0, 0]]) - info = load_info(obj) + info = get_info(obj) rep = build_object_html(info) assert "Polygon (4 vertices)" in rep + def test_multipolygon(): obj = ee.Geometry.MultiPolygon( [ @@ -188,7 +153,7 @@ def test_multipolygon(): [[4, 6], [3, 2], [1, 2], [4, 6]], ] ) - info = load_info(obj) + info = get_info(obj) rep = build_object_html(info) assert "MultiPolygon (8 vertices)" in rep @@ -196,14 +161,15 @@ def test_multipolygon(): def test_linearring(): obj = ee.Geometry.LinearRing([[0, 0], [1, 1], [2, 2], [0, 0]]) - info = load_info(obj) + info = get_info(obj) rep = build_object_html(info) assert "LinearRing (4 vertices)" in rep + def test_daterange(): obj = ee.DateRange("2020-01-01T21:01:10", "2022-03-01T14:32:11") - info = load_info(obj) + info = get_info(obj) rep = build_object_html(info) assert "DateRange [2020-01-01 21:01:10, 2022-03-01 14:32:11]" in rep @@ -211,11 +177,12 @@ def test_daterange(): def test_typed_obj(): obj = ee.Dictionary({"type": "Foo", "id": "bar"}) - info = load_info(obj) + info = get_info(obj) rep = build_object_html(info) assert "Foo bar" in rep + def test_band(): band_id = "B1" data_type = {"type": "PixelType", "precision": "int", "min": 0, "max": 65535} @@ -223,38 +190,38 @@ def test_band(): crs = "EPSG:32610" band1 = ee.Dictionary( - { - "id": band_id, - "data_type": data_type, - "dimensions": dimensions, - "crs": crs, - } - ) - band1_info = load_info(band1) + { + "id": band_id, + "data_type": data_type, + "dimensions": dimensions, + "crs": crs, + } + ) + band1_info = get_info(band1) band1_rep = build_object_html(band1_info) assert '"B1", unsigned int16, EPSG:32610, 1830x1830 px' in band1_rep band2 = ee.Dictionary( - { - "id": band_id, - "data_type": data_type, - } - ) - band2_info = load_info(band2) + { + "id": band_id, + "data_type": data_type, + } + ) + band2_info = get_info(band2) band2_rep = build_object_html(band2_info) assert '"B1", unsigned int16' in band2_rep def test_pixel_types(): - assert "float" in build_object_html(load_info(ee.PixelType.float())) - assert "double" in build_object_html(load_info(ee.PixelType.double())) - assert "signed int8" in build_object_html(load_info(ee.PixelType.int8())) - assert "unsigned int8" in build_object_html(load_info(ee.PixelType.uint8())) - assert "signed int16" in build_object_html(load_info(ee.PixelType.int16())) - assert "unsigned int16" in build_object_html(load_info(ee.PixelType.uint16())) - assert "signed int32" in build_object_html(load_info(ee.PixelType.int32())) - assert "unsigned int32" in build_object_html(load_info(ee.PixelType.uint32())) - assert "signed int64" in build_object_html(load_info(ee.PixelType.int64())) + assert "float" in build_object_html(get_info(ee.PixelType.float())) + assert "double" in build_object_html(get_info(ee.PixelType.double())) + assert "signed int8" in build_object_html(get_info(ee.PixelType.int8())) + assert "unsigned int8" in build_object_html(get_info(ee.PixelType.uint8())) + assert "signed int16" in build_object_html(get_info(ee.PixelType.int16())) + assert "unsigned int16" in build_object_html(get_info(ee.PixelType.uint16())) + assert "signed int32" in build_object_html(get_info(ee.PixelType.int32())) + assert "unsigned int32" in build_object_html(get_info(ee.PixelType.uint32())) + assert "signed int64" in build_object_html(get_info(ee.PixelType.int64())) custom_type = dict(type="PixelType", min=10, max=255, precision="int") - assert "int ∈ [10, 255]" in build_object_html(load_info(ee.Dictionary(custom_type))) \ No newline at end of file + assert "int ∈ [10, 255]" in build_object_html(get_info(ee.Dictionary(custom_type))) diff --git a/tests/test_reprs.py b/tests/test_reprs.py deleted file mode 100644 index 57ca61a..0000000 --- a/tests/test_reprs.py +++ /dev/null @@ -1,11 +0,0 @@ -import ee -import pytest - -import eerepr - - -def test_error(): - """Test that an object that raises on getInfo falls abck to the string repr and warns.""" - with pytest.warns(UserWarning): - rep = ee.Projection("not a real epsg")._repr_html_() - assert "ee.Projection object" in rep