Skip to content
Closed
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
21 changes: 11 additions & 10 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
name: Publish Python Package

# Trigger on:
# - pushes to any branch (we'll publish to TestPyPI for non-main branches and to PyPI for main)
# - pull requests targeting main (publish to TestPyPI for validation before merge)
# - pushes to main (publish to PyPI after merge)
# - published GitHub Releases (keep existing behavior for canonical releases)
on:
pull_request:
branches: [main]
push:
branches: ["**"]
branches: [main]
release:
types: [published]

Expand Down Expand Up @@ -41,16 +44,13 @@ jobs:
name: python-package-distributions
path: dist/

# Publish from pushes to non-main branches -> TestPyPI
# Publish from pull requests to main -> TestPyPI
publish_testpypi:
name: Publish to TestPyPI (branches except main)
name: Publish to TestPyPI (PR to main)
runs-on: ubuntu-latest
needs: build_sdist_and_wheel
# Only run for pushes (not releases) and only for branches that are NOT main
if: |
github.event_name == 'push' &&
startsWith(github.ref, 'refs/heads/') &&
github.ref != 'refs/heads/main'
# Only run for pull request events targeting main
if: github.event_name == 'pull_request' && github.base_ref == 'main'
Comment on lines +47 to +53
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Publishing to TestPyPI on every pull request to main could create issues:

  1. Version conflicts: Multiple PRs will try to publish the same version (2.0.3), causing failures after the first PR
  2. Unnecessary builds: This will publish packages for PRs that might not be merged
  3. The skip-existing: true parameter on line 71 only partially addresses this

Consider one of these alternatives:

  • Only publish to TestPyPI on manual workflow dispatch or specific labels
  • Use unique version suffixes for PR builds (e.g., 2.0.3.dev{pr_number})
  • Remove PR publishing entirely and only publish from main/releases

Copilot uses AI. Check for mistakes.
environment: testpypi
permissions:
id-token: write
Expand All @@ -68,6 +68,7 @@ jobs:
with:
# repository-url directs upload to TestPyPI
repository-url: https://test.pypi.org/legacy/
skip-existing: true

# Publish from pushes to main OR when a Release is published -> Production PyPI
publish_pypi:
Expand All @@ -93,4 +94,4 @@ jobs:
path: dist/

- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@release/v1
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,4 @@ fmd-server/
fmd-android/

#credentials file
examples/tests/credentials.txt
examples/tests/credentials.txt
48 changes: 48 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Pre-commit hooks configuration
# See https://pre-commit.com for more information
repos:
# General file cleanup
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: '^tests/functional/'
- id: end-of-file-fixer
exclude: '^tests/functional/'
- id: check-yaml
- id: check-toml
- id: check-json
- id: check-added-large-files
args: ['--maxkb=1000']
- id: check-merge-conflict
- id: debug-statements
- id: mixed-line-ending
args: ['--fix=lf']

# Python code formatting with black
- repo: https://github.com/psf/black
rev: 24.10.0
hooks:
- id: black
language_version: python3

# Python linting with flake8
- repo: https://github.com/pycqa/flake8
rev: 7.1.1
hooks:
- id: flake8
args: ['--count', '--show-source', '--statistics']
exclude: '^tests/functional/'

# Python type checking with mypy
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
hooks:
- id: mypy
additional_dependencies:
- types-aiofiles
- aiohttp
- argon2-cffi
- cryptography
args: ['--install-types', '--non-interactive']
exclude: '^tests/'
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ Tips:
- `set_ringer_mode("normal|vibrate|silent")`
- `get_device_stats()`


- Low‑level: `decrypt_data_blob(b64_blob)`

- `Device` helper (per‑device convenience)
Expand Down Expand Up @@ -171,4 +171,4 @@ This client targets the FMD ecosystem:
- https://gitlab.com/fmd-foss
- Public community instance: https://fmd.nulide.de/

MIT © 2025 Devin Slick
MIT © 2025 Devin Slick
12 changes: 6 additions & 6 deletions docs/HOME_ASSISTANT_REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ async def _make_api_request(self, ..., timeout: int = 30):
- `pyproject.toml`: `2.0.0.dev8` (PEP 440 compliant)
- `_version.py`: `2.0.0-dev8` (uses hyphen instead of dot)

**Location:**
**Location:**
- `pyproject.toml` line 3
- `fmd_api/_version.py` line 1

Expand Down Expand Up @@ -143,7 +143,7 @@ if resp.status == 429:
class FmdClient:
async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc, tb):
await self.close()
```
Expand Down Expand Up @@ -191,9 +191,9 @@ async with await FmdClient.create(...) as client:
- Line ~88: Logs may include auth details
- Line ~203: Logs full JSON responses which may contain tokens

**Fix:**
**Fix:**
- Sanitize all log output
- Mask tokens: `log.debug(f"Token: {token[:8]}...")`
- Mask tokens: `log.debug(f"Token: {token[:8]}...")`
- Add guards: `if log.isEnabledFor(logging.DEBUG):`

**HA Rationale:** Security and privacy requirement for production systems.
Expand Down Expand Up @@ -364,7 +364,7 @@ async def decrypt_data_blob_async(self, data_b64: str) -> bytes:
async def get_locations(...) -> List[str]:
"""
...

Raises:
AuthenticationError: If authentication fails
FmdApiException: If server returns error
Expand All @@ -384,7 +384,7 @@ async def get_locations(...) -> List[str]:

**Location:** Test configuration

**Fix:**
**Fix:**
- Add `pytest-cov` to dev dependencies
- Configure coverage in `pyproject.toml`
- Add coverage reporting to CI workflow
Expand Down
44 changes: 22 additions & 22 deletions docs/MIGRATE_FROM_V1.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,16 +175,16 @@ from fmd_api import FmdApi

async def main():
api = await FmdApi.create("https://fmd.example.com", "alice", "secret")

# Request new location
await api.request_location('gps')
await asyncio.sleep(30)

# Get locations
blobs = await api.get_all_locations(1)
location_json = api.decrypt_data_blob(blobs[0])
location = json.loads(location_json)

print(f"Lat: {location['lat']}, Lon: {location['lon']}")
await api.close()

Expand All @@ -199,16 +199,16 @@ from fmd_api import FmdClient

async def main():
client = await FmdClient.create("https://fmd.example.com", "alice", "secret")

# Request new location
await client.request_location('gps')
await asyncio.sleep(30)

# Get locations
blobs = await client.get_locations(1)
location_json = client.decrypt_data_blob(blobs[0])
location = json.loads(location_json)

print(f"Lat: {location['lat']}, Lon: {location['lon']}")
await client.close()

Expand All @@ -223,14 +223,14 @@ from fmd_api import FmdClient, Device
async def main():
client = await FmdClient.create("https://fmd.example.com", "alice", "secret")
device = Device(client, "alice")

# Request and get location (simplified)
await client.request_location('gps')
await asyncio.sleep(30)

location = await device.get_location(force=True)
print(f"Lat: {location.lat}, Lon: {location.lon}")

await client.close()

asyncio.run(main())
Expand All @@ -244,16 +244,16 @@ from fmd_api import FmdApi, FmdCommands

async def control_device():
api = await FmdApi.create("https://fmd.example.com", "alice", "secret")

# Using constants
await api.send_command(FmdCommands.RING)
await api.send_command(FmdCommands.BLUETOOTH_ON)

# Using convenience methods
await api.toggle_bluetooth(True)
await api.toggle_do_not_disturb(True)
await api.set_ringer_mode('vibrate')

await api.close()
```

Expand All @@ -263,16 +263,16 @@ from fmd_api import FmdClient

async def control_device():
client = await FmdClient.create("https://fmd.example.com", "alice", "secret")

# Use strings directly (constants removed)
await client.send_command('ring')
await client.send_command('bluetooth on')

# Using convenience methods (renamed from toggle_* to set_*)
await client.set_bluetooth(True)
await client.set_do_not_disturb(True)
await client.set_ringer_mode('vibrate')

await client.close()
```

Expand All @@ -283,15 +283,15 @@ from fmd_api import FmdClient, Device
async def control_device():
client = await FmdClient.create("https://fmd.example.com", "alice", "secret")
device = Device(client, "alice")

# Use device methods for cleaner API
await device.play_sound()

# Settings still use client
await client.set_bluetooth(True)
await client.set_do_not_disturb(True)
await client.set_ringer_mode('vibrate')

await client.close()
```

Expand All @@ -304,13 +304,13 @@ from fmd_api import FmdApi

async def get_history():
api = await FmdApi.create("https://fmd.example.com", "alice", "secret")

blobs = await api.get_all_locations(10)
for blob in blobs:
location_json = api.decrypt_data_blob(blob)
location = json.loads(location_json)
print(f"Date: {location['date']}, Lat: {location['lat']}, Lon: {location['lon']}")

await api.close()
```

Expand All @@ -321,11 +321,11 @@ from fmd_api import FmdClient, Device
async def get_history():
client = await FmdClient.create("https://fmd.example.com", "alice", "secret")
device = Device(client, "alice")

# Async iterator with automatic decryption
async for location in device.get_history(limit=10):
print(f"Date: {location.date}, Lat: {location.lat}, Lon: {location.lon}")

await client.close()
```

Expand Down
6 changes: 3 additions & 3 deletions docs/PROPOSAL.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Proposal: fmd_api v2 — Device-centric async interface

Status: Draft
Author: devinslick (proposal by Copilot Space)
Status: Draft
Author: devinslick (proposal by Copilot Space)
Date: 2025-11-01

## Goals
Expand Down Expand Up @@ -286,4 +286,4 @@ This proposal updates the earlier draft to:
- Include explicit methods for taking front and rear photos.
- Drop an in-code legacy compatibility layer and instead provide a small migration README.

If you approve, I will create a branch and a PR that implements the core FmdClient and Device class with the initial methods (authenticate, get_devices, get_device, Device.refresh, Device.get_location, Device.play_sound, Device.take_front_photo, Device.take_rear_photo), plus tests and example usage.
If you approve, I will create a branch and a PR that implements the core FmdClient and Device class with the initial methods (authenticate, get_devices, get_device, Device.refresh, Device.get_location, Device.play_sound, Device.take_front_photo, Device.take_rear_photo), plus tests and example usage.
2 changes: 1 addition & 1 deletion docs/PROPOSED_BRANCH_AND_STRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,4 @@ Next steps after branch creation:
5. Iterate on rate-limiter/cache and add streaming helpers for export_data_zip.

If you'd like, I can now generate the initial skeleton files for this branch (client.py, device.py, types.py, exceptions.py, helpers.py, docs/MIGRATE_FROM_V1.md, examples/async_example.py, PROPOSAL.md). Which files would you like me to create first?
```
```
2 changes: 1 addition & 1 deletion fmd_api/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "2.0.2"
__version__ = "2.0.3"
14 changes: 3 additions & 11 deletions fmd_api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,13 +317,9 @@ async def _make_api_request(
continue

# Transient server errors -> retry (except for unsafe command POSTs)
if resp.status in (500, 502, 503, 504) and not (
is_command and method.upper() == "POST"
):
if resp.status in (500, 502, 503, 504) and not (is_command and method.upper() == "POST"):
if attempts_left > 0:
delay = _compute_backoff(
self.backoff_base, backoff_attempt, self.backoff_max, self.jitter
)
delay = _compute_backoff(self.backoff_base, backoff_attempt, self.backoff_max, self.jitter)
log.warning(
f"Server error {resp.status}. "
f"Retrying in {delay:.2f}s ({attempts_left} retries left)..."
Expand All @@ -350,11 +346,7 @@ async def _make_api_request(
# Sanitize: don't log full JSON which may contain tokens/sensitive data
if log.isEnabledFor(logging.DEBUG):
# Log safe metadata only
keys = (
list(json_data.keys())
if isinstance(json_data, dict)
else "non-dict"
)
keys = list(json_data.keys()) if isinstance(json_data, dict) else "non-dict"
log.debug(f"{endpoint} JSON response received with keys: {keys}")
return json_data["Data"]
except (KeyError, ValueError, json.JSONDecodeError) as e:
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "fmd_api"
version = "2.0.2"
version = "2.0.3"
authors = [{name = "devinslick"}]
description = "A Python client for the FMD (Find My Device) server API"
readme = "README.md"
Expand Down Expand Up @@ -45,6 +45,7 @@ dev = [
"black",
"flake8",
"mypy",
"pre-commit",
]

# --- IMPORTANT CHANGE ---
Expand Down Expand Up @@ -77,4 +78,4 @@ exclude_lines = [
"raise NotImplementedError",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]
]
Loading
Loading