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 .github/release-drafter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ autolabeler:
title:
- '/(fix|bug|missing|correct)/i'
- label: '🧹 Updates'
title:
title:
- '/(improve|update|refactor|deprecated|remove|unused|test)/i'
- label: '🤖 Dependencies'
title:
Expand Down
24 changes: 15 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,26 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: pip

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Set up uv
uses: astral-sh/setup-uv@v6

- name: Run Django system checks
run: python manage.py check
run: uv run --no-project --with-requirements requirements-dev.txt python manage.py check

- name: Run pre-commit
run: pre-commit run --all-files
run: uv run --no-project --with-requirements requirements-dev.txt pre-commit run --all-files

- name: Analyze dependency licenses
run: uv run --no-project --with-requirements requirements-dev.txt --with pip-licenses pip-licenses --format=markdown --with-authors --with-urls --output-file licenses-report.md

- name: Upload license report
uses: actions/upload-artifact@v4
with:
name: licenses-report-${{ matrix.os }}-py${{ matrix.python-version }}
path: licenses-report.md

- name: Run unit tests with coverage
run: |
coverage run manage.py test
coverage report --fail-under=80
uv run --no-project --with-requirements requirements-dev.txt coverage run manage.py test
uv run --no-project --with-requirements requirements-dev.txt coverage report --fail-under=80
36 changes: 36 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: "CodeQL"

on:
push:
branches:
- main
pull_request:
schedule:
- cron: "24 3 * * 1"

permissions:
actions: read
contents: read
security-events: write

jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language:
- python

steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}

- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v4
27 changes: 27 additions & 0 deletions .github/workflows/release-drafter.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Release Drafter

on:
push:
branches:
- main
pull_request:
types:
- opened
- reopened
- synchronize
- ready_for_review

permissions:
contents: read
pull-requests: write

jobs:
update_release_draft:
runs-on: ubuntu-latest
steps:
- name: Draft release notes
uses: release-drafter/release-drafter@v6
with:
config-name: release-drafter.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# PyBehaviorLog 0.9.5

[![CI](https://github.com/PyBehaviorLog/PyBehaviorLog/actions/workflows/ci.yml/badge.svg)](https://github.com/PyBehaviorLog/PyBehaviorLog/actions/workflows/ci.yml)
[![CodeQL](https://github.com/PyBehaviorLog/PyBehaviorLog/actions/workflows/codeql.yml/badge.svg)](https://github.com/PyBehaviorLog/PyBehaviorLog/actions/workflows/codeql.yml)
[![Release Drafter](https://github.com/PyBehaviorLog/PyBehaviorLog/actions/workflows/release-drafter.yml/badge.svg)](https://github.com/PyBehaviorLog/PyBehaviorLog/actions/workflows/release-drafter.yml)

PyBehaviorLog is an ASGI-first behavioral observation platform built with Django 6.0.3. It is designed for research teams who need video-assisted coding, live observations, structured ethograms, review workflows, and exportable analytics without being locked into a desktop-only workflow.

## What is in this version
Expand Down
13 changes: 11 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,17 @@ exclude = [
]

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
ignore = ["E501"]
select = ["E", "W", "F", "I", "B", "UP", "RUF", "SIM", "C4"]
ignore = [
"E501",
# Django model/form Meta inner classes use mutable class attributes intentionally.
"RUF012",
# Existing code patterns intentionally trade strictness for readability in this project.
"RUF005",
"RUF010",
"RUF046",
"C416",
]

[tool.ruff.format]
quote-style = "single"
Expand Down
13 changes: 10 additions & 3 deletions tracker/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,21 @@ class ObservationSessionAdmin(admin.ModelAdmin):
inlines = [SessionVideoInline, VariableValueInline]




@admin.register(ObservationSegment)
class ObservationSegmentAdmin(admin.ModelAdmin):
list_display = ('session', 'title', 'start_seconds', 'end_seconds', 'status', 'assignee', 'reviewer')
list_display = (
'session',
'title',
'start_seconds',
'end_seconds',
'status',
'assignee',
'reviewer',
)
list_filter = ('session__project', 'status')
search_fields = ('session__title', 'title', 'notes', 'assignee__username', 'reviewer__username')


@admin.register(ObservationEvent)
class ObservationEventAdmin(admin.ModelAdmin):
list_display = (
Expand Down
58 changes: 42 additions & 16 deletions tracker/compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,6 @@ def _resolve_annotation_items(payload: dict[str, Any]) -> list[dict[str, Any]]:
return []




def _resolve_segment_items(payload: dict[str, Any]) -> list[dict[str, Any]]:
if payload.get('schema', '').startswith('pybehaviorlog-'):
return [item for item in payload.get('segments', []) if isinstance(item, dict)]
Expand Down Expand Up @@ -103,13 +101,19 @@ def normalize_session_payload(payload: dict[str, Any]) -> dict[str, Any]:
event_kind = str(item.get('event_kind') or item.get('type') or 'point').lower()
events.append(
{
'time': _normalize_time(item.get('time') or item.get('timestamp_seconds') or item.get('start')),
'time': _normalize_time(
item.get('time') or item.get('timestamp_seconds') or item.get('start')
),
'behavior': str(behavior),
'event_kind': event_kind,
'modifiers': _string_list(item.get('modifiers')),
'subjects': _string_list(item.get('subjects') or item.get('subject')),
'comment': str(item.get('comment') or item.get('comment_start') or item.get('image_path') or ''),
'frame_index': int(item.get('frame_index') or item.get('frame') or 0) if str(item.get('frame_index') or item.get('frame') or '').strip() else None,
'comment': str(
item.get('comment') or item.get('comment_start') or item.get('image_path') or ''
),
'frame_index': int(item.get('frame_index') or item.get('frame') or 0)
if str(item.get('frame_index') or item.get('frame') or '').strip()
else None,
}
)
events.sort(key=lambda item: (item['time'], item['behavior'], item['event_kind']))
Expand All @@ -124,12 +128,14 @@ def normalize_session_payload(payload: dict[str, Any]) -> dict[str, Any]:
annotations.sort(key=lambda item: (item['time'], item['text']))
segments = []
for item in _resolve_segment_items(payload):
segments.append({
'title': str(item.get('title') or ''),
'start': _normalize_time(item.get('start_seconds') or item.get('start')),
'end': _normalize_time(item.get('end_seconds') or item.get('end')),
'status': str(item.get('status') or ''),
})
segments.append(
{
'title': str(item.get('title') or ''),
'start': _normalize_time(item.get('start_seconds') or item.get('start')),
'end': _normalize_time(item.get('end_seconds') or item.get('end')),
'status': str(item.get('status') or ''),
}
)
segments.sort(key=lambda item: (item['start'], item['end'], item['title']))
variables = payload.get('variables') or payload.get('independent_variables') or {}
if not isinstance(variables, dict):
Expand All @@ -139,7 +145,9 @@ def normalize_session_payload(payload: dict[str, Any]) -> dict[str, Any]:
'events': events,
'annotations': annotations,
'variables': {str(key): str(value) for key, value in sorted(variables.items())},
'media_paths': sorted(_string_list(payload.get('media_paths') or payload.get('image_paths'))),
'media_paths': sorted(
_string_list(payload.get('media_paths') or payload.get('image_paths'))
),
'segments': segments,
}

Expand Down Expand Up @@ -169,6 +177,7 @@ def compare_session_payloads(expected: dict[str, Any], actual: dict[str, Any]) -

def normalize_project_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""Normalize project-like payloads for BORIS/PyBehaviorLog round-trip comparisons."""

def _item_names(value: Any, *, key: str = 'name', fallback: str = 'label') -> list[str]:
results = []
if isinstance(value, dict):
Expand Down Expand Up @@ -200,15 +209,21 @@ def _item_names(value: Any, *, key: str = 'name', fallback: str = 'label') -> li
if isinstance(observations, list):
for observation in observations:
if isinstance(observation, dict):
session_titles.append(str(observation.get('title') or observation.get('description') or ''))
session_titles.append(
str(observation.get('title') or observation.get('description') or '')
)
return {
'schema_family': str(payload.get('schema') or 'unknown'),
'categories': _item_names(payload.get('categories')),
'behaviors': _item_names(payload.get('behaviors')),
'modifiers': _item_names(payload.get('modifiers')),
'subject_groups': _item_names(payload.get('subject_groups')),
'subjects': _item_names(payload.get('subjects')),
'variables': _item_names(payload.get('variables') or payload.get('independent_variables'), key='label', fallback='name'),
'variables': _item_names(
payload.get('variables') or payload.get('independent_variables'),
key='label',
fallback='name',
),
'templates': _item_names(payload.get('observation_templates')),
'sessions': sorted(item for item in session_titles if item),
}
Expand All @@ -219,7 +234,16 @@ def compare_project_payloads(expected: dict[str, Any], actual: dict[str, Any]) -
actual_normalized = normalize_project_payload(actual)
mismatches = [
key
for key in ('categories', 'behaviors', 'modifiers', 'subject_groups', 'subjects', 'variables', 'templates', 'sessions')
for key in (
'categories',
'behaviors',
'modifiers',
'subject_groups',
'subjects',
'variables',
'templates',
'sessions',
)
if expected_normalized[key] != actual_normalized[key]
]
return {
Expand All @@ -230,7 +254,9 @@ def compare_project_payloads(expected: dict[str, Any], actual: dict[str, Any]) -
}


def build_roundtrip_report(expected: dict[str, Any], actual: dict[str, Any], family: str) -> dict[str, Any]:
def build_roundtrip_report(
expected: dict[str, Any], actual: dict[str, Any], family: str
) -> dict[str, Any]:
"""Build a machine-readable round-trip report for CI and fixture certification."""
comparator = compare_project_payloads if family == 'project' else compare_session_payloads
comparison = comparator(expected, actual)
Expand Down
Loading
Loading