Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
- [ ] **Advanced Rollout Strategies:** Support for percentage rollouts, user targeting, and A/B testing.
- [ ] **Async Support:** Add async/await support for non-blocking flag fetching and updates.
- [ ] **Type Annotations & Validation:** Improve type safety and validation for flag values and operations.
- [ ] **Better Error Handling & Logging:** More granular error reporting and logging options.
- [x] **Better Error Handling & Logging:** More granular error reporting and logging options.
- [x] **Extensive Documentation & Examples:** Expand documentation with more real-world usage patterns and advanced scenarios.

Contributions and suggestions are welcome! Please open an issue or pull request if you have ideas for improvements.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "python_flaggle"
version = "0.2.1"
version = "0.3.0"
description = ""
authors = [
{name = "Asaph Diniz", email = "contato@asaph.dev.br"}
Expand Down
2 changes: 1 addition & 1 deletion python_flaggle/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@
from python_flaggle.flag import Flag, FlagOperation, FlagType

__all__ = ["FlagType", "FlagOperation", "Flag", "Flaggle"]
__version__ = "0.2.1"
__version__ = "0.3.0"
__author__ = "Asaph Diniz"
__email__ = "contato@asaph.dev.br"
17 changes: 14 additions & 3 deletions python_flaggle/flag.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,15 @@ def from_string(cls, operation: str) -> "FlagOperation":
ValueError: If the operation name is invalid.
"""
operation = operation.upper()

try:
return cls.__dict__[operation]
except KeyError as exc:
logger.error("Invalid Operation '%s'", operation)
logger.debug(format_exc())
raise ValueError(f"Invalid Operation '{operation}'") from exc
except Exception as exc:
logger.critical("Unexpected error in FlagOperation.from_string: %s", exc, exc_info=True)
raise


class Flag:
Expand Down Expand Up @@ -247,13 +249,14 @@ def from_json(cls: "Flag", data: dict) -> dict[str, "Flag"]:
try:
flags_data = data.get("flags")
if flags_data is None or not isinstance(flags_data, list):
logger.error("No flags in the provided JSON data: %r", data)
raise ValueError("No flags in the provided JSON data")

result = {}
for flag_data in flags_data:
name = flag_data.get("name")
if not name:
logger.warning("Found flag without name, skipping")
logger.warning("Found flag without name, skipping: %r", flag_data)
continue

value = flag_data.get("value")
Expand All @@ -262,11 +265,19 @@ def from_json(cls: "Flag", data: dict) -> dict[str, "Flag"]:
operation_str = flag_data.get("operation")
operation = None
if operation_str:
operation = FlagOperation.from_string(operation_str)
try:
operation = FlagOperation.from_string(operation_str)
except Exception as exc:
logger.error("Invalid operation '%s' for flag '%s': %s", operation_str, name, exc, exc_info=True)
raise ValueError("Invalid JSON data: invalid operation") from exc

result[name] = cls(name, value, description, operation)

return result

except (KeyError, AttributeError) as exc:
logger.error("Invalid JSON data: %s", exc, exc_info=True)
raise ValueError(f"Invalid JSON data: {exc}") from exc
except Exception as exc:
logger.critical("Unexpected error in Flag.from_json: %s", exc, exc_info=True)
raise ValueError(f"Invalid JSON data: {exc}") from exc
51 changes: 34 additions & 17 deletions python_flaggle/flaggle.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,44 +150,61 @@ def _fetch_flags(self) -> dict[str, list[dict[str, str]]]:
logger.info("Fetching flags from %s", self._url)
response = get(self._url, timeout=self._timeout, verify=self._verify_ssl)
response.raise_for_status()

logger.info("Flags fetched successfully")
logger.info("Flags fetched successfully from %s", self._url)
logger.debug("Response content: %s", response.text)
logger.warning("Response[%i]: %r", response.status_code, response.json())

logger.debug("Response[%i]: %r", response.status_code, response.json())
return Flag.from_json(response.json())
except RequestException as e:
print(f"Error fetching flags: {e}")
logger.error("Error fetching flags from %s: %s", self._url, e, exc_info=True)
return {}
except ValueError as e:
logger.error("Invalid response format from %s: %s", self._url, e, exc_info=True)
return {}
except (KeyError, ValueError):
logger.error("Invalid response format: 'flags' key not found")
except Exception as e:
logger.critical("Unexpected error during flag fetch: %s", e, exc_info=True)
return {}

def _update(self) -> None:
"""
Update the internal flag dictionary by fetching the latest flags.
"""
flags_data = self._fetch_flags()
if flags_data:
self._flags = flags_data
self._last_update = datetime.now(timezone.utc)
logger.info("Flags updated successfully")
logger.debug("Current flags: %s", self._flags)
try:
flags_data = self._fetch_flags()
if flags_data:
self._flags = flags_data
self._last_update = datetime.now(timezone.utc)
logger.info("Flags updated successfully at %s", self._last_update)
logger.debug("Current flags: %s", self._flags)
else:
logger.warning("No flags data received; keeping previous flags.")
except Exception as e:
logger.critical("Unexpected error during flag update: %s", e, exc_info=True)

def _schedule_update(self) -> None:
"""
Start the background scheduler for periodic flag updates.
"""
def run_scheduler():
self._scheduler.enter(self._interval, 1, self.recurring_update)
self._scheduler.run()
try:
self._scheduler.enter(self._interval, 1, self.recurring_update)
self._scheduler.run()
except Exception as e:
logger.critical("Scheduler thread encountered an error: %s", e, exc_info=True)

self._scheduler_thread = Thread(target=run_scheduler, daemon=True)
self._scheduler_thread.start()
logger.info("Flag update scheduler started (interval=%s seconds)", self._interval)

def recurring_update(self) -> None:
"""
Periodically update flags at the configured interval.
"""
self._update()
self._scheduler.enter(self._interval, 1, self.recurring_update)
try:
self._update()
except Exception as e:
logger.error("Error during recurring flag update: %s", e, exc_info=True)
finally:
try:
self._scheduler.enter(self._interval, 1, self.recurring_update)
except Exception as e:
logger.critical("Failed to reschedule recurring update: %s", e, exc_info=True)
5 changes: 4 additions & 1 deletion tests/test_flag.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,10 @@ def test_from_json_no_flag_name(self):
with patch("python_flaggle.flag.logger.warning") as mock_warning:
flag = Flag.from_json(json_data)
assert flag == {}
mock_warning.assert_called_once_with("Found flag without name, skipping")
mock_warning.assert_called_once_with(
"Found flag without name, skipping: %r",
{"description": "a test", "value": "testflag", "operation": "eq"},
)

def test_from_json_invalid_json_data(self):
json_data = {
Expand Down
74 changes: 74 additions & 0 deletions tests/test_flaggle_extra.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import logging
import threading
import types
from unittest.mock import MagicMock, patch

import pytest

from python_flaggle import Flag, Flaggle


Expand Down Expand Up @@ -114,3 +118,73 @@ def test_flaggle_properties(monkeypatch):
assert f.verify_ssl is False
assert f.flags == f._flags
assert f.last_update == f._last_update


def test_flaggle_fetch_flags_unexpected_exception(monkeypatch, caplog):
"""Test that _fetch_flags handles unexpected exceptions and logs critical."""

class DummyFlaggle(Flaggle):
pass

def raise_exc(*a, **k):
raise RuntimeError("unexpected")

monkeypatch.setattr("python_flaggle.flaggle.get", raise_exc)
f = DummyFlaggle("http://x", interval=1, default_flags={"f": Flag("f", True)})
with caplog.at_level(logging.CRITICAL):
result = f._fetch_flags()
assert result == {}
assert any(
"Unexpected error during flag fetch" in r.message for r in caplog.records
)


def test_flaggle_update_handles_exception(monkeypatch, caplog):
"""Test that _update logs critical on unexpected exception."""

class DummyFlaggle(Flaggle):
pass

f = DummyFlaggle("http://x", interval=1, default_flags={"f": Flag("f", True)})

def raise_exc():
raise RuntimeError("fail update")

f._fetch_flags = raise_exc
with caplog.at_level(logging.CRITICAL):
f._update()
assert any(
"Unexpected error during flag update" in r.message for r in caplog.records
)


def test_flaggle_recurring_update_reschedule_exception(monkeypatch, caplog):
"""Test that recurring_update logs critical if rescheduling fails."""
f = Flaggle("http://x", interval=1, default_flags={"f": Flag("f", True)})

def raise_enter(*a, **k):
raise RuntimeError("fail reschedule")

f._scheduler.enter = raise_enter
with caplog.at_level(logging.CRITICAL):
f.recurring_update()
assert any(
"Failed to reschedule recurring update" in r.message for r in caplog.records
)


def test_flaggle_recurring_update_update_exception(monkeypatch, caplog):
"""Test that recurring_update logs error if _update fails."""
f = Flaggle("http://x", interval=1, default_flags={"f": Flag("f", True)})

def raise_update():
raise RuntimeError("fail update")

f._update = raise_update
# Patch _scheduler.enter to a no-op to avoid further errors
f._scheduler.enter = lambda *a, **k: None
with caplog.at_level(logging.ERROR):
f.recurring_update()
assert any(
"Error during recurring flag update" in r.message for r in caplog.records
)