Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
588c3b9
chg: remove unused question_type xls + code
lindsay-stevens Sep 1, 2025
848117f
chg: update and expand tests on loops
lindsay-stevens Sep 3, 2025
e296c9c
add: test coverage for unmatched group begin/end
lindsay-stevens Sep 4, 2025
9002113
chg: improve error messages for unmatched groups
lindsay-stevens Sep 4, 2025
a4e05b1
add: test coverage for name uniqueness rules
lindsay-stevens Sep 9, 2025
f8fd743
chg: make name uniqueness rules more consistent
lindsay-stevens Sep 9, 2025
7a3d3cd
chg: move entities tests into folder
lindsay-stevens Sep 10, 2025
a187792
add: allow entities to be declared for a repeat
lindsay-stevens Sep 10, 2025
ba42054
add: name to unmatched control error messages
lindsay-stevens Sep 15, 2025
cdce738
chg: improve error messages
lindsay-stevens Sep 15, 2025
c736045
dev: merge branch 'master' into 'pyxform-775'
lindsay-stevens Sep 19, 2025
2caa1e2
add: set entity id when creating new repeat
lindsay-stevens Sep 19, 2025
0af166a
chg: set new entities version when repeat used
lindsay-stevens Sep 19, 2025
df6eee6
chg: improve entities xpath helper, refactor tests
lindsay-stevens Sep 19, 2025
1e481b9
chg: standardise/fix/split action node output
lindsay-stevens Oct 1, 2025
026bfe0
fix: repeat entity label binding path too low
lindsay-stevens Oct 2, 2025
0014ca9
fix: repeat entity attribute binding paths too low
lindsay-stevens Oct 7, 2025
983b990
add: error if entity save_to in undeclared repeat
lindsay-stevens Oct 7, 2025
3c8287e
fix: only emit repeat setvalue with create mode
lindsay-stevens Oct 9, 2025
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
1 change: 0 additions & 1 deletion pyxform/aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,6 @@
"audio": survey_header["audio"],
"video": survey_header["video"],
}
# Note that most of the type aliasing happens in all.xls
_type_alias_map = {
"imei": "deviceid",
"image": "photo",
Expand Down
12 changes: 10 additions & 2 deletions pyxform/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
OSM_TYPE = "binary"

NAMESPACES = "namespaces"
META = "meta"

# The following are the possible sheet names:
SUPPORTED_SHEET_NAMES = {
Expand Down Expand Up @@ -113,10 +114,15 @@
# The ODK XForms version that generated forms comply to
CURRENT_XFORMS_VERSION = "1.0.0"


# The ODK entities spec version that generated forms comply to
ENTITIES_OFFLINE_VERSION = "2024.1.0"
class EntityVersion(StrEnum):
v2024_1_0 = "2024.1.0"
v2025_1_0 = "2025.1.0"


ENTITY = "entity"
ENTITY_FEATURES = "entity_features"
ENTITY_VERSION = "entity_version"
ENTITIES_RESERVED_PREFIX = "__"


Expand All @@ -125,6 +131,7 @@ class EntityColumns(StrEnum):
ENTITY_ID = "entity_id"
CREATE_IF = "create_if"
UPDATE_IF = "update_if"
REPEAT = "repeat"
LABEL = "label"


Expand Down Expand Up @@ -169,3 +176,4 @@ class EntityColumns(StrEnum):
}
SUPPORTED_MEDIA_TYPES = {"image", "big-image", "audio", "video"}
OR_OTHER_CHOICE = {NAME: "other", LABEL: "Other"}
RESERVED_NAMES_SURVEY_SHEET = {META}
Empty file added pyxform/elements/__init__.py
Empty file.
158 changes: 158 additions & 0 deletions pyxform/elements/action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
from enum import Enum

from pyxform.elements.element import Element
from pyxform.util.enum import StrEnum
from pyxform.utils import node


class Event(StrEnum):
"""
Supported W3C XForms 1.1 Events and ODK extensions.
"""

# For actions in model under /html/head/model
ODK_INSTANCE_FIRST_LOAD = "odk-instance-first-load"
ODK_INSTANCE_LOAD = "odk-instance-load"
# For actions in repeat control under /html/body
ODK_NEW_REPEAT = "odk-new-repeat"
# For actions in question control under /html/body
XFORMS_VALUE_CHANGED = "xforms-value-changed"


class Action(Element):
"""
Base class for supported Action elements.

https://getodk.github.io/xforms-spec/#actions
"""

__slots__ = ("event", "kwargs", "ref", "value")

def __init__(
self,
ref: str,
event: Event,
value: str | None = None,
**kwargs,
):
super().__init__()
self.ref: str = ref
self.event: Event = event
self.value: str | None = value

def node(self):
return node(self.name, ref=self.ref, event=self.event.value)


class Setvalue(Action):
"""
Explicitly sets the value of the specified instance data node.

https://getodk.github.io/xforms-spec/#action:setvalue
"""

name = "setvalue"

def __init__(self, ref: str, event: Event, value: str | None = None, **kwargs):
super().__init__(ref=ref, event=event, value=value, **kwargs)

def node(self):
result = super().node()
if self.value:
result.setAttribute("value", self.value)
return result


class ODKSetGeopoint(Action):
"""
Sets the current location's geopoint value in the instance data node specified in the
ref attribute.

https://getodk.github.io/xforms-spec/#action:setgeopoint
"""

name = "odk:setgeopoint"
Copy link
Contributor

Choose a reason for hiding this comment

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

It feels slightly uncomfortable to me to hard code the namespace prefix here since it has to match the namespace declaration. An alternative would be to define a constant for the prefix somewhere and use it consistently here and in the declaration. It's not super important because it's an obviously good convention that doesn't require much discipline to maintain but wanted to mention it. I probably feel discomfort because I'm used to seeing code on the form client side where it's important to support any prefix.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

good point for improvement though it was hard-coded previously (question_type_dictionary.py etc)


def __init__(
self,
ref: str,
event: Event,
value: str | None = None,
**kwargs,
):
super().__init__(ref=ref, event=event, value=value, **kwargs)


class ODKRecordAudio(Action):
"""
Records audio starting at the triggering event, saves the audio to a file, and writes
the filename to the node specified in the ref attribute.

https://getodk.github.io/xforms-spec/#action:recordaudio
"""

__slots__ = ("quality",)
name = "odk:recordaudio"

def __init__(
self,
ref: str,
event: Event,
value: str | None = None,
**kwargs,
):
super().__init__(ref=ref, event=event, value=value, **kwargs)
self.quality: str | None = kwargs.get("odk:quality")

def node(self):
result = super().node()
if self.quality:
result.setAttribute("odk:quality", self.quality)
return result


ACTION_CLASSES = {
"setvalue": Setvalue,
"odk:setgeopoint": ODKSetGeopoint,
"odk:recordaudio": ODKRecordAudio,
}


class LibraryMember:
def __init__(self, name: str, event: str):
self.name: str = name
self.event: str = event

def to_dict(self):
return {"name": self.name, "event": self.event}


class ActionLibrary(Enum):
"""
A collection of action/event configs used by pyxform.
"""

setvalue_first_load = LibraryMember(
name=Setvalue.name,
event=Event.ODK_INSTANCE_FIRST_LOAD.value,
)
setvalue_new_repeat = LibraryMember(
name=Setvalue.name,
event=Event.ODK_NEW_REPEAT.value,
)
setvalue_value_changed = LibraryMember(
name=Setvalue.name,
event=Event.XFORMS_VALUE_CHANGED.value,
)
odk_setgeopoint_first_load = LibraryMember(
name=ODKSetGeopoint.name,
event=Event.ODK_INSTANCE_FIRST_LOAD.value,
)
odk_setgeopoint_value_changed = LibraryMember(
name=ODKSetGeopoint.name,
event=Event.XFORMS_VALUE_CHANGED.value,
)
odk_recordaudio_instance_load = LibraryMember(
name=ODKRecordAudio.name,
event=Event.ODK_INSTANCE_LOAD.value,
)
21 changes: 21 additions & 0 deletions pyxform/elements/element.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from pyxform.utils import DetachableElement


class Element:
"""
Base class for Element interface and default behaviour.

Unlike SurveyElement which may emit multiple elements for a particular survey
component, this class is for defining individual elements for output.
"""

name: str

def node(self) -> "DetachableElement":
"""
Create the element.
"""
raise NotImplementedError()
Loading