pcdshub / pytmc

Generate EPICS IOCs and records from TwinCAT projects - along with many TwinCAT project tools
https://pcdshub.github.io/pytmc/
Other
10 stars 11 forks source link

ENH: include actions, methods, and properties in `pytmc code` #274

Closed klauer closed 2 years ago

klauer commented 2 years ago

Closes #270 Closes #211

So, um

~The actual result here may not be "valid" IEC 61131-3 method/action definition code. Why? Well, TwinCAT, for example, does not store the source code of methods in plain text along with its respective function block. It has an XML container that describes a hierarchy of "POU contains actions, methods, declaration, implementation". So in the method definition itself, METHOD methodname there does not need to be a reference to the containing POU.~

~What is suggested and implemented here is the only thing I can think of - aside from giving up on plain text altogether for these - is to prefix methods and actions with FB_Name - e.g., FB_Name.MethodName.~

~This will have consequences downstream for anything desiring to parse pytmc's "code" output (it'll be vital for blark, once I get there; otherwise it'll have no idea which method goes with which POU).~

~Open to thoughts/suggestions for alternatives.~

Example

Property and method example:

``` $ pytmc code LCLSGeneral/POUs/Logger/FB_Listener.TcPOU FUNCTION_BLOCK FB_Listener EXTENDS FB_ListenerBase VAR_INPUT END_VAR VAR_OUTPUT END_VAR VAR nEventIdx : UINT := 0; nPendingEvents : UINT := 0; {attribute 'pytmc' := ' pv: LogToVisualStudio io: io '} bLogToVisualStudio : BOOL := FALSE; {attribute 'pytmc' := ' pv: MessagesSent io: i '} nCntMessagesSent : UDINT := 0; {attribute 'pytmc' := ' pv: AlarmsRaised io: i '} nCntAlarmsRaised : UDINT := 0; {attribute 'pytmc' := ' pv: AlarmsConfirmed io: i '} nCntAlarmsConfirmed : UDINT := 0; {attribute 'pytmc' := ' pv: AlarmsCleared io: i '} nCntAlarmsCleared : UDINT := 0; {attribute 'pytmc' := ' pv: MinSeverity io: io '} eMinSeverity : TcEventSeverity; {attribute 'pytmc' := ' pv: Log '} stEventInfo : REFERENCE TO ST_LoggingEventInfo; stPendingEvents : ARRAY [0..nMaxEvents - 1] OF ST_PendingEvent; ipMessageConfig : ITcEventFilterConfig; fbSocket : POINTER TO FB_ConnectionlessSocket; bConfigured : BOOL := FALSE; END_VAR VAR_IN_OUT END_VAR VAR CONSTANT // The maximum number of events allowed *per-cycle* nMaxEvents : UINT := 10; END_VAR END_FUNCTION_BLOCK (* Configure an event class + severity *) METHOD Configure : HRESULT VAR_INPUT i_EventClass : GUID; i_MinSeverity : TcEventSeverity := TcEventSeverity.Verbose; i_fbSocket : POINTER TO FB_ConnectionlessSocket; END_VAR VAR_INST bSubscribed : BOOL := FALSE; END_VAR IF bSubscribed THEN Unsubscribe(); END_IF THIS^.Subscribe(ADR(ipMessageConfig), 0); bSubscribed := TRUE; eMinSeverity := i_MinSeverity; fbSocket := i_fbSocket; IF (ipMessageConfig = 0) THEN Configure := 1; bConfigured := FALSE; ELSE ipMessageConfig.AddEventClass(i_EventClass, i_MinSeverity); Configure := 0; bConfigured := TRUE; END_IF END_METHOD METHOD OnAlarmCleared VAR_INPUT fbEvent : REFERENCE TO FB_TcEvent; END_VAR (* Callback run from THIS^.Execute() *) nCntAlarmsCleared := nCntAlarmsCleared + 1; StoreEvent(fbEvent, eEventType:=E_LogEventType.AlarmCleared); END_METHOD METHOD OnAlarmConfirmed VAR_INPUT fbEvent : REFERENCE TO FB_TcEvent; END_VAR (* Callback run from THIS^.Execute() *) nCntAlarmsConfirmed := nCntAlarmsConfirmed + 1; StoreEvent(fbEvent, eEventType:=E_LogEventType.AlarmConfirmed); END_METHOD METHOD OnAlarmRaised VAR_INPUT fbEvent : REFERENCE TO FB_TcEvent; END_VAR (* Callback run from THIS^.Execute() *) nCntAlarmsRaised := nCntAlarmsRaised + 1; StoreEvent(fbEvent, eEventType:=E_LogEventType.AlarmRaised); END_METHOD METHOD OnMessageSent VAR_INPUT fbEvent : REFERENCE TO FB_TcEvent; END_VAR (* Callback run from THIS^.Execute() *) nCntMessagesSent := nCntMessagesSent + 1; StoreEvent(fbEvent, eEventType:=E_LogEventType.MessageSent); END_METHOD METHOD PublishEvents : HRESULT VAR nEvent : UINT; stPendingEvent : REFERENCE TO ST_PendingEvent; stEventInfo : REFERENCE TO ST_LoggingEventInfo; fbRequestEventText : REFERENCE TO FB_RequestEventText; END_VAR VAR_INST fbJson : FB_JsonSaxWriter; fbJsonDataType : FB_JsonReadWriteDataType; sJsonDoc : STRING(10000); END_VAR IF nPendingEvents = 0 THEN RETURN; END_IF FOR nEvent := 0 TO nMaxEvents - 1 DO stPendingEvent REF= stPendingEvents[nEvent]; IF NOT stPendingEvent.bInUse THEN CONTINUE; END_IF fbRequestEventText REF= stPendingEvent.fbRequestEventText; stEventInfo REF= stPendingEvent.stEventInfo; IF fbRequestEventText.bError THEN stEventInfo.Msg := '(Unable to retrieve message)'; ELSIF NOT fbRequestEventText.bBusy THEN fbRequestEventText.GetString(stEventInfo.msg, SIZEOF(stEventInfo.msg)); ELSE CONTINUE; END_IF IF bConfigured THEN stEventInfo.plc := GVL_Logger.sPlcHostname; // Generate the JSON message fbJson.ResetDocument(); fbJsonDataType.AddJsonValueFromSymbol(fbJson, 'ST_LoggingEventInfo', SIZEOF(stEventInfo), ADR(stEventInfo)); fbJson.CopyDocument(sJsonDoc, SIZEOF(sJsonDoc)); SendMessage(sMessage:=ADR(sJsonDoc)); END_IF // Mark as not in use, and fill in this event in the next StoreEvent call nPendingEvents := nPendingEvents - 1; stPendingEvent.bInUse := FALSE; nEventIdx := nEvent; END_FOR END_METHOD METHOD SendMessage : HRESULT VAR_INPUT sMessage : POINTER TO STRING; END_VAR VAR sLogStr : T_MaxString; END_VAR (* For subclasses to override, if necessary *) IF sMessage = 0 THEN RETURN; END_IF // Optionally log it to Visual Studio's message list IF bLogToVisualStudio THEN // Keep the message length under 255 (extended string function for LEFT/MID do not exist) STRNCPY(ADR(sLogStr), sMessage, MIN(220, SIZEOF(sLogStr))); ADSLOGSTR( msgCtrlMask := ADSLOG_MSGTYPE_HINT, msgFmtStr := '[Logger JSON Debug] %s', strArg := sLogStr ); END_IF IF fbSocket <> 0 THEN // And send it along to logstash F_SendUDPMessage(sMessage:=sMessage, fbSocket:=fbSocket^, sHost:=GVL_Logger.cLogHost, iPort:=GVL_Logger.iLogPort); END_IF END_METHOD METHOD PRIVATE StoreEvent : HRESULT VAR_INPUT fbEvent : REFERENCE TO FB_TcEvent; eEventType : E_LogEventType; END_VAR VAR stPendingEvent : REFERENCE TO ST_PendingEvent; stEventInfo : REFERENCE TO ST_LoggingEventInfo; nFailures : UINT := 0; END_VAR IF fbEvent.eSeverity < eMinSeverity THEN // Ignore all messages below the minimum severity RETURN; ELSIF NOT __ISVALIDREF(fbEvent) THEN RETURN; END_IF // Find the next slot to use in stPendingEvents WHILE stPendingEvents[nEventIdx].bInUse AND nFailures < nMaxEvents DO nFailures := nFailures + 1; IF ((nEventIdx := (nEventIdx + 1)) = nMaxEvents) THEN nEventIdx := 0; END_IF END_WHILE IF (nFailures = nMaxEvents) THEN ADSLOGSTR( msgCtrlMask := ADSLOG_MSGTYPE_ERROR, msgFmtStr := 'Logging message buffer full (%s)', strArg := UINT_TO_STRING(nMaxEvents), ); RETURN; END_IF nPendingEvents := nPendingEvents + 1; nCntMessagesSent := nCntMessagesSent + 1; stPendingEvent REF= stPendingEvents[nEventIdx]; stEventInfo REF= stPendingEvent.stEventInfo; stPendingEvent.bInUse := TRUE; stEventInfo.id := fbEvent.nEventId; stEventInfo.event_class := GUID_TO_STRING(fbEvent.EventClass); stEventInfo.severity := fbEvent.eSeverity; stEventInfo.ts := F_ConvertTicksToUnixTimestamp(fbEvent.nTimestamp); stEventInfo.source := fbEvent.ipSourceInfo.sName; stEventInfo.event_type := eEventType; fbEvent.GetJsonAttribute(stEventInfo.json, SIZEOF(stEventInfo.json)); stPendingEvent.fbRequestEventText.Request(eventClass:=fbEvent.EventClass, nEventId:=fbEvent.nEventId, nLangId:=1033, ipArgs:=fbEvent.ipArguments); END_METHOD PROPERTY PUBLIC LogToVisualStudio : BOOL VAR END_VAR LogToVisualStudio := bLogToVisualStudio; END_PROPERTY PROPERTY PUBLIC LogToVisualStudio : BOOL VAR bValue : BOOL; END_VAR THIS^.bLogToVisualStudio := bValue; END_PROPERTY ```

Action example:

``` $ pytmc code old_plcs/HOMS_FEE/HOMS_FEE/HOMS_FEE_PLC/POUs/MAIN.TcPOU PROGRAM MAIN VAR tpImAPLC : TP := (PT:=T#10S); //stDiag : ST_fbDiagnostics; //FEEM1PitchControl : FB_PitchControl; FEEM1PitchControl : FB_PitchControl; FEEM1Y_GantryControl : FB_Gantry; FEEM1X_GantryControl : FB_Gantry; FEEM2PitchControl : FB_PitchControl; FEEM2Y_GantryControl : FB_Gantry; FEEM2X_GantryControl : FB_Gantry; FEEM1_BenderControl : FB_PTP; // Expert mode permits direct access to individual acutators. ExpertMode : BOOL := FALSE; DirectPiezoMode: BOOL := FALSE; //Limit switch evaluation blocks fbM1Y1LS : FB_LimitSwitchState; fbM1Y2LS : FB_LimitSwitchState; fbM1X1LS : FB_LimitSwitchState; fbM1X2LS : FB_LimitSwitchState; fbM1P1LS : FB_PitchSoftLimits; fbM2Y1LS : FB_LimitSwitchState; fbM2Y2LS : FB_LimitSwitchState; fbM2X1LS : FB_LimitSwitchState; fbM2X2LS : FB_LimitSwitchState; fbM2P1LS : FB_PitchSoftLimits; //Drive CoE Fb fbY1CoE : FB_ElmoGDCBellCoE; fbY2CoE : FB_ElmoGDCBellCoE; fbX1CoE : FB_ElmoGDCBellCoE; fbX2CoE : FB_ElmoGDCBellCoE; fbP1CoE : FB_ElmoGDCBellCoE; fbB1CoE : FB_ElmoGDCBellCoE; //Init params trigger rtInitParams : R_TRIG; //Test Parameters rStepSize: REAL; SettleTime: TON := ( PT:=T#2s); TestUpperVoltage: REAL := 100; TestLowerVoltage: REAL := 10; xRunPiezoRepeatTest: BOOL; //Drive reference initializations fbInitDriveRef : FB_InitDriveRefs; END_VAR //System IO ///////////////////////////// p_ModbusRx_OutputCoils(); p_ModbusRx_PLCMemory(); //DriveCoEIO(); //This is now done in the FB_PTP block //InitParams //////////////////// rtInitParams(CLK:=gFirstPass); IF rtInitParams.Q THEN Init_FEEM1(); Init_FEEM2(); gFirstPass := FALSE; END_IF //System Error Handling ///////////////////////////////////// //SystemErrorSummary(); //Check this action for a summary of all system faults //SystemError(); //Driver Parameter Verification ///////////////////////////////////// //Gantry Interlock Logic ///////////////////////////////////// //SimGantry.VAxis.i_xMotorInterlock := TRUE; //SimGantry.PAxis.i_xMotorInterlock := TRUE; //SimGantry.SAxis.i_xMotorInterlock := TRUE; //P_GantryDiffSup(); //This is now done in FB_Gantry block LimitSwitchIO(); // Pitch control ///////////////////////////////////// FEEM1PitchControl(Pitch:=HOMS1_Pitch, DirectPiezoMode:=(ExpertMode AND DirectPiezoMode), q_xDone=>HOMS1_Pitch.Axis.bDone, q_xBusy=>HOMS1_Pitch.Axis.bBusy); FEEM2PitchControl(Pitch:=HOMS2_Pitch, DirectPiezoMode:=(ExpertMode AND DirectPiezoMode), q_xDone=>HOMS2_Pitch.Axis.bDone, q_xBusy=>HOMS2_Pitch.Axis.bBusy); // Bender control ////////////////////////////////// FEEM1_BenderControl(Stepper:=HOMS1_Bender); //Y motion ///////////////////////////////////// FEEM1Y_GantryControl(Gantry:=HOMS1_YGantry); FEEM2Y_GantryControl(Gantry:=HOMS2_YGantry); //X Motion ///////////////////////////////////// FEEM1X_GantryControl(Gantry:=HOMS1_XGantry); FEEM2X_GantryControl(Gantry:=HOMS2_XGantry); //EPICS Tx Update //////////////////// p_ModbusTx_InputCoils(); //p_ModbusTx_OutputCoils(); p_ModbusTx_InputReg(); p_ModbusTx_PLCMemory(); END_PROGRAM ACTION Init_FEEM1: //FEE-M1 /////////////////////////// //Initialize Center positions HOMS1_XGantry.PAxis.cCenter := HOMS1_XGantry_PAxisCenter; HOMS1_XGantry.SAxis.cCenter := HOMS1_XGantry_SAxisCenter; HOMS1_YGantry.PAxis.cCenter := HOMS1_YGantry_PAxisCenter; HOMS1_YGantry.SAxis.cCenter := HOMS1_YGantry_SAxisCenter; //Initialize ELMO Drive references fbInitDriveRef(stCoE:=HOMS1_XGantry.PAxis.stCoE); fbInitDriveRef(stCoE:=HOMS1_XGantry.SAxis.stCoE); fbInitDriveRef(stCoE:=HOMS1_YGantry.PAxis.stCoE); fbInitDriveRef(stCoE:=HOMS1_YGantry.SAxis.stCoE); fbInitDriveRef(stCoE:=HOMS1_Pitch.Stepper.stCoE); //Initialze Drive Speeds and Accelerations ////////////////////////////////////////// HOMS1_XGantry.PAxis.fAcceleration := 0.02; HOMS1_XGantry.SAxis.fAcceleration := 0.02; HOMS1_YGantry.PAxis.fAcceleration := 0.02; HOMS1_YGantry.SAxis.fAcceleration := 0.02; HOMS1_XGantry.PAxis.fDeceleration := 0.02; HOMS1_XGantry.SAxis.fDeceleration := 0.02; HOMS1_YGantry.PAxis.fDeceleration := 0.02; HOMS1_YGantry.SAxis.fDeceleration := 0.02; HOMS1_XGantry.PAxis.fVelocity := 0.1; HOMS1_XGantry.SAxis.fVelocity := 0.1; HOMS1_YGantry.PAxis.fVelocity := 0.1; HOMS1_YGantry.SAxis.fVelocity := 0.1; HOMS1_Pitch.Stepper.fAcceleration := 39; HOMS1_Pitch.Stepper.fDeceleration := 39; HOMS1_Pitch.Stepper.fVelocity := 39; //Initialize Piezo Driver ///////////////////////////////////// HOMS1_Pitch.Piezo.stPIParams.fKp := 0.001; HOMS1_Pitch.Piezo.stPIParams.tTn := T#100mS; HOMS1_Pitch.Piezo.stPIParams.fOutMaxLimit := 1; HOMS1_Pitch.Piezo.stPIParams.fOutMinLimit := -1; END_ACTION ACTION Init_FEEM2: //FEE-M2 /////////////////////////// //Initialize Center positions HOMS2_XGantry.PAxis.cCenter := HOMS2_XGantry_PAxisCenter; HOMS2_XGantry.SAxis.cCenter := HOMS2_XGantry_SAxisCenter; HOMS2_YGantry.PAxis.cCenter := HOMS2_YGantry_PAxisCenter; HOMS2_YGantry.SAxis.cCenter := HOMS2_YGantry_SAxisCenter; //Initialize ELMO Drive references fbInitDriveRef(stCoE:=HOMS2_XGantry.PAxis.stCoE); fbInitDriveRef(stCoE:=HOMS2_XGantry.SAxis.stCoE); fbInitDriveRef(stCoE:=HOMS2_YGantry.PAxis.stCoE); fbInitDriveRef(stCoE:=HOMS2_YGantry.SAxis.stCoE); fbInitDriveRef(stCoE:=HOMS2_Pitch.Stepper.stCoE); //Initialze Drive Speeds and Accelerations ////////////////////////////////////////// HOMS2_XGantry.PAxis.fAcceleration := 0.02; HOMS2_XGantry.SAxis.fAcceleration := 0.02; HOMS2_YGantry.PAxis.fAcceleration := 0.02; HOMS2_YGantry.SAxis.fAcceleration := 0.02; HOMS2_XGantry.PAxis.fDeceleration := 0.02; HOMS2_XGantry.SAxis.fDeceleration := 0.02; HOMS2_YGantry.PAxis.fDeceleration := 0.02; HOMS2_YGantry.SAxis.fDeceleration := 0.02; HOMS2_XGantry.PAxis.fVelocity := 0.1; HOMS2_XGantry.SAxis.fVelocity := 0.1; HOMS2_YGantry.PAxis.fVelocity := 0.1; HOMS2_YGantry.SAxis.fVelocity := 0.1; HOMS2_Pitch.Stepper.fAcceleration := 39; HOMS2_Pitch.Stepper.fDeceleration := 39; HOMS2_Pitch.Stepper.fVelocity := 39; //Initialize Piezo Driver ///////////////////////////////////// HOMS2_Pitch.Piezo.stPIParams.fKp := 0.001; HOMS2_Pitch.Piezo.stPIParams.tTn := T#100mS; HOMS2_Pitch.Piezo.stPIParams.fOutMaxLimit := 1; HOMS2_Pitch.Piezo.stPIParams.fOutMinLimit := -1; END_ACTION ACTION LimitSwitchIO: //FEE M1 /////////////////// fbM1Y1LS(diInputs:=HOMS1_YGantry.PAxis.diInputs, xHiLS=>HOMS1_YGantry.PAxis.xHiLS, xLoLS=>HOMS1_YGantry.PAxis.xLoLS); fbM1Y2LS(diInputs:=HOMS1_YGantry.SAxis.diInputs, xHiLS=>HOMS1_YGantry.SAxis.xHiLS, xLoLS=>HOMS1_YGantry.SAxis.xLoLS); fbM1X1LS(diInputs:=HOMS1_XGantry.PAxis.diInputs, xHiLS=>HOMS1_XGantry.PAxis.xHiLS, xLoLS=>HOMS1_XGantry.PAxis.xLoLS); fbM1X2LS(diInputs:=HOMS1_XGantry.SAxis.diInputs, xHiLS=>HOMS1_XGantry.SAxis.xHiLS, xLoLS=>HOMS1_XGantry.SAxis.xLoLS); fbM1P1LS(Pitch:=HOMS1_Pitch, xHiLS=>HOMS1_Pitch.Stepper.xHiLS, xLoLS=>HOMS1_Pitch.Stepper.xLoLS); //FEE M2 ////////////////////////// fbM2Y1LS(diInputs:=HOMS2_YGantry.PAxis.diInputs, xHiLS=>HOMS2_YGantry.PAxis.xHiLS, xLoLS=>HOMS2_YGantry.PAxis.xLoLS); fbM2Y2LS(diInputs:=HOMS2_YGantry.SAxis.diInputs, xHiLS=>HOMS2_YGantry.SAxis.xHiLS, xLoLS=>HOMS2_YGantry.SAxis.xLoLS); fbM2X1LS(diInputs:=HOMS2_XGantry.PAxis.diInputs, xHiLS=>HOMS2_XGantry.PAxis.xHiLS, xLoLS=>HOMS2_XGantry.PAxis.xLoLS); fbM2X2LS(diInputs:=HOMS2_XGantry.SAxis.diInputs, xHiLS=>HOMS2_XGantry.SAxis.xHiLS, xLoLS=>HOMS2_XGantry.SAxis.xLoLS); fbM2P1LS(Pitch:=HOMS2_Pitch, xHiLS=>HOMS2_Pitch.Stepper.xHiLS, xLoLS=>HOMS2_Pitch.Stepper.xLoLS); END_ACTION ```

Additionally

I misconfigured my text editor at some point, leading to brackets-in-quotes wreaking havoc on further input. Please excuse the additional end-of-line comments that close those open brackets.

klauer commented 2 years ago

At least one more thing to add, I just realized - PROPERTY is missing, too.

ZLLentz commented 2 years ago

Is this a typo in the description's example?

$ pytmc code pytmc code FB_PositionStatePMPS_Base.TcPOU
ZLLentz commented 2 years ago

The tradeoffs here with making the "invalid" twincat code seem well-motivated. I don't think there are any plans to use this project to generate runnable twincat programs, right?

klauer commented 2 years ago

Fixed example, consolidated code (PROPERTY made it almost a necessity). Added more syntactical oddness with PROPERTY:

PROPERTY PUBLIC FB_Listener.LogToVisualStudio.GET : BOOL
VAR
END_VAR
LogToVisualStudio := bLogToVisualStudio;
END_PROPERTY
PROPERTY PUBLIC FB_Listener.LogToVisualStudio.SET :
VAR
    bValue : BOOL;
END_VAR
THIS^.bLogToVisualStudio := bValue;
END_PROPERTY

The unfortunate nature of action/method is even worse here, as there is now a getter/setter, and one has a return type whereas the other does not.

(If any external PLC coder people take a look at this output, they're likely to be rather confused. 🤷‍♂️ Sorry, PLC people!)

ZLLentz commented 2 years ago

Random thought: would it better to have the output look something this?

// Context: FB_Listener.LogToVisualStudio.GET
PROPERTY PUBLIC GET : BOOL
VAR
END_VAR
LogToVisualStudio := bLogToVisualStudio;
END_PROPERTY

// Context: FB_Listener.LogToVisualStudio.SET
PROPERTY PUBLIC SET :
VAR
    bValue : BOOL;
END_VAR
THIS^.bLogToVisualStudio := bValue;
END_PROPERTY

e.g., annotated real code rather than edited code?

klauer commented 2 years ago

I'll give it some thought, @ZLLentz - I'm not sure, to be honest. I'm pretty sure I'll need something where I can fully derive the context of a getter/setter/method based on its name - or at least something that's not a comment, though.

For reference, the underlying source from TwinCAT is: https://github.com/pcdshub/lcls-twincat-general/blob/d757b7fe70c695f852da11cb648394b9edb52a83/LCLSGeneral/LCLSGeneral/POUs/Logger/FB_Listener.TcPOU#L109-L129

klauer commented 2 years ago

I think I'll walk back my above statement a bit. It feels a bit off, but requires no modifications to syntax for ACTION/METHOD, at least:

FUNCTION_BLOCK abc
END_FUNCTION_BLOCK

ACTION action_name
END_ACTION

METHOD method_name
END_METHOD

action_name and method_name can be assumed to be members of the most-recently-defined FUNCTION_BLOCK, even without a qualified abc.method_name in the definition.

However, PROPERTY is a bit different. I suppose the getter and setter could be defined with the same name, and one could have a return value and the other not?

The getter:

PROPERTY PUBLIC LogToVisualStudio : BOOL
VAR
END_VAR
LogToVisualStudio := bLogToVisualStudio;
END_PROPERTY

The setter:

PROPERTY PUBLIC LogToVisualStudio
VAR
    bValue : BOOL;
END_VAR
THIS^.bLogToVisualStudio := bValue;
END_PROPERTY

Seem a bit better, @ZLLentz?

ZLLentz commented 2 years ago

I don't fully understand all the implications, nor can I immediately tell the difference. I just thought it was worth considering if it was possible to annotate instead of modify. If it has at least been considered then I have no further input :+1:

klauer commented 2 years ago

OK, I walked back the dotted methods and such. I think this is probably good to go.

PROPERTY may still deserve a follow-up at some point (it's annoying to not know if it's the getter or setter, at the very least).