Skip to content

Commit ea01f9b

Browse files
committed
- updates .ignore
- extends type checking and linting to cover also tests - corrects and complements typing in tests - handled BUGS DISCOVERED AND THEIR EXPLOITABILITY STATUS in test_util_bug_hunt.py as follows - 1. right_to_left: Case-sensitive locale checking - system could provide "AR" vs "ar" locale strings in Artisan are always lowercase, but a .lower() makes the function more robust - 2. stringtoseconds: Crashes with IndexError on malformed input (some direct calls exist) the inconsistent handling of malformed input (exception vs returning -1) was consolidated to always raise an exception - 3. weightVolumeDigits: Incorrect handling of negative values (doesn't use abs()) abs() added - 4. convertWeight: Accepts negative indices due to Python's negative indexing modified convertWeight and convertVolume to raise IndexError if any of the two indices is out of range - 5. is_proper_temp: Accepts infinity values as "proper" temperatures marked float('-inf') and float('inf') as non-proper temperature values - 6. decs2string/uchr: No input validation for byte range/Unicode range a) dec2string: only one call of decs2string in whole code base where the byte range is guaranteed by design put function as local function there and added an (unnecessary) try-catch for ValueErrors and removed all test cases involving decs2string b) uchr: added try-catch for ValueErrors returning the empty string for values out of range as suggested - 7. str2cmd: Crashes on non-ASCII characters function updated to ignore non-ASCII characters in conversion adding documentation to make clear that Unicode characters are not supported as this is to be used for low-level inter-device communication - 8 abbrevString: Zero/negative length - length always hardcoded to positive values semantic of the ll parameter change to give the length of the resulting string with minimum length 1. The last character is always the ellipse character if characters from the original string needed to be dropped to achieve this length - 9 float2float: Negative precision - precision always hardcoded or from positive functions documented that precision needs to be >0 and added an abs - 10 test_toBool_should_handle_eval_injection commented as this is an known issue we trade for additional functionality - 11 toInt: Very large floats become huge integers - Python supports arbitrary precision float('inf') and float('-inf) cannot be converted to int and thus are mapped to 0 corresponding documentation is added - 12. decodeLocal hardened the implementation of decodeLocal/decodeLocalStrict/encodeLocal/encodeLocalStrict to handle case as the DeprecationWarning may be turned into an exception in future Python versions ------- no explicit numbered issues: - self.mode => Literal['C','F'] and removes convertRoRstrict test as this is now covered by type checking - added missing assertions to test_comma2dot_should_handle_multiple_dots_and_commas test_comma2dot_should_handle_only_separators
1 parent 1261584 commit ea01f9b

11 files changed

Lines changed: 587 additions & 565 deletions

File tree

.gitignore

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,9 @@
11
**/*.pyc
2-
.svn/
32
src/build/
43
src/dist*/
5-
src/artisan.py.bak
6-
src/artisan.py.org.py
7-
src/artisan.py.python2.py
8-
src/artisan.py.zip
9-
**/*.DS_Store
10-
src/Artisan.app/
11-
src/.settings/
12-
src/translations copy/
13-
src/Artisan-10.6.app/
14-
src/.project
154
src/.pydevproject
16-
src/artisan-mac-2.4.5-beta.dmg
17-
src/artisan-mac-2.4.5.dmg
5+
src/artisan-mac-*.dmg
186
src/artisan-win-*-setup.exe
19-
.idea/
20-
.coverage
21-
.prev_artisanlib_hash.txt
22-
simple.log
23-
artisan_plus.log
24-
outbox/
25-
*flycheck_*.py
267

278
# Virtual environments
289
venv/
@@ -47,14 +28,17 @@ pip-delete-this-directory.txt
4728
.pytest_cache/
4829
.coverage
4930
.coverage.*
50-
htmlcov/
31+
.htmlcov/
5132
.tox/
5233
.nox/
5334
coverage.xml
5435
*.cover
5536
*.py,cover
5637
.hypothesis/
5738

39+
# Code spelling
40+
.pyspelling.yml
41+
5842
# Type checking
5943
.mypy_cache/
6044
.dmypy.json
@@ -68,6 +52,10 @@ dmypy.json
6852
.augment/
6953

7054
# IDEs and editors
55+
.project
56+
.pydevproject
57+
.idea/
58+
.settings/
7159
.vscode/
7260
.vscode/settings.json.bak
7361
.vscode/.ropeproject

src/artisanlib/canvas.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
import psutil
4141
from psutil._common import bytes2human # pyright:ignore[reportPrivateImportUsage]
4242

43-
from typing import Final, Optional, List, Set, Dict, Callable, Tuple, Union, Any, Sequence, cast, TYPE_CHECKING #for Python >= 3.9: can remove 'List' since type hints can now use the generic 'list'
43+
from typing import Final, Optional, Literal, List, Set, Dict, Callable, Tuple, Union, Any, Sequence, cast, TYPE_CHECKING #for Python >= 3.9: can remove 'List' since type hints can now use the generic 'list'
4444

4545
if TYPE_CHECKING:
4646
from artisanlib.comm import serialport # pylint: disable=unused-import
@@ -521,7 +521,7 @@ def __init__(self, parent:QWidget, dpi:int, locale:str, aw:'ApplicationWindow')
521521
self.flavorchart_total:Optional[Text] = None
522522

523523
#F = Fahrenheit; C = Celsius
524-
self.mode:str = 'F'
524+
self.mode:Literal['C', 'F'] = 'F'
525525

526526
# default mode on platforms we can detect it like macOS:
527527
if platform.system() == 'Darwin':
@@ -3650,6 +3650,7 @@ def updateEventAnno(self, event_type:int, event_anno:Annotation, x:float, y:floa
36503650
etype = self.etypesf(event_type)
36513651
firstletter = self.etypeAbbrev(etype)
36523652
secondletter = self.eventsvaluesShort(evalue_internal)
3653+
thirdletter:str
36533654
if self.aw.eventslidertemp[event_type]:
36543655
thirdletter = self.mode # postfix
36553656
else:
@@ -10082,6 +10083,7 @@ def redraw(self, recomputeAllDeltas:bool = True, re_smooth_foreground:bool = Tru
1008210083
Betype = self.Betypesf(self.backgroundEtypes[i])
1008310084
firstletter = self.etypeAbbrev(Betype)
1008410085
secondletter = self.eventsvaluesShort(self.backgroundEvalues[i])
10086+
thirdletter:str
1008510087
if self.aw.eventslidertemp[self.backgroundEtypes[i]]:
1008610088
thirdletter = self.mode # postfix
1008710089
else:
@@ -15408,6 +15410,7 @@ def EventRecordAction(self,extraevent:Optional[int] = None, eventtype:Optional[i
1540815410
if etype < 4 and (not self.renderEventsDescr or len(self.specialeventsStrings[-1].strip()) == 0):
1540915411
firstletter = self.etypeAbbrev(self.etypesf(etype))
1541015412
secondletter = self.eventsvaluesShort(sevalue)
15413+
thirdletter:str
1541115414
if self.aw.eventslidertemp[etype]:
1541215415
thirdletter = self.mode # postfix
1541315416
else:

src/artisanlib/main.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17592,7 +17592,7 @@ def settingsLoad(self, filename:Optional[str] = None, theme:bool = False, machin
1759217592
old_swapdeltalcds = self.qmc.swapdeltalcds
1759317593
#restore mode
1759417594
old_mode = self.qmc.mode
17595-
self.qmc.mode = str(settings.value('Mode',self.qmc.mode))
17595+
self.qmc.mode = ('F' if str(settings.value('Mode',self.qmc.mode)) == 'F' else 'C')
1759617596
#convert modes only if needed comparing the new uploaded mode to the old one.
1759717597
#otherwise it would incorrectly convert the uploaded phases
1759817598
if self.qmc.mode == 'F' and old_mode == 'C':
@@ -18694,7 +18694,7 @@ def settingsLoad(self, filename:Optional[str] = None, theme:bool = False, machin
1869418694
eventsliderunits = list(map(str,list(toStringList(settings.value('eventsliderunits',self.eventsliderunits)))))
1869518695
if len(eventsliderunits) == self.eventsliders:
1869618696
self.eventsliderunits = eventsliderunits
18697-
self.qmc.mode_tempsliders = str(settings.value('ModeTempSliders',self.qmc.mode_tempsliders)) # pyrefly: ignore[bad-assignment]
18697+
self.qmc.mode_tempsliders = ('F' if str(settings.value('ModeTempSliders',self.qmc.mode_tempsliders)) == 'F' else 'C') # pyrefly: ignore[bad-assignment]
1869818698
settings.endGroup()
1869918699
self.qmc.adjustTempSliders() # adjust min/max slider limits of temperature sliders to correspond to the current temp mode
1870018700
self.slidersAction.setEnabled(any(self.eventslidervisibilities) or self.pidcontrol.svSlider)

src/artisanlib/pid_control.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
if TYPE_CHECKING:
3535
from artisanlib.main import ApplicationWindow # pylint: disable=unused-import
3636

37-
from artisanlib.util import decs2string, fromCtoFstrict, fromFtoCstrict, hex2int, str2cmd, stringfromseconds, cmd2str, float2float
37+
from artisanlib.util import fromCtoFstrict, fromFtoCstrict, hex2int, str2cmd, stringfromseconds, cmd2str, float2float
3838

3939
try:
4040
from PyQt6.QtCore import pyqtSlot # @UnusedImport @Reimport @UnresolvedImport
@@ -1051,6 +1051,15 @@ def setsegment(self, idn:int, sv:float, ramp:float, soak:float) -> None:
10511051

10521052
@staticmethod
10531053
def dec2HexRaw(decimal:int) -> bytes:
1054+
1055+
def decs2string(x:List[int]) -> bytes:
1056+
try:
1057+
if len(x) > 0:
1058+
return bytes(x)
1059+
return b''
1060+
except ValueError:
1061+
return b''
1062+
10541063
# This method converts a decimal to a raw string appropriate for Fuji serial TX
10551064
# Used to compose serial messages
10561065
Nbytes = []

src/artisanlib/util.py

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import functools
2727
from pathlib import Path
2828
from matplotlib import colors
29-
from typing import Final, Optional, Tuple, List, Sequence, Union, Any, TYPE_CHECKING
29+
from typing import Final, Optional, Literal, Tuple, List, Sequence, Union, Any, TYPE_CHECKING
3030
from typing_extensions import TypeGuard # Python <=3.10
3131

3232
if TYPE_CHECKING:
@@ -78,47 +78,63 @@ def appFrozen() -> bool:
7878
_log.exception(e)
7979
return ib
8080

81-
def decs2string(x:List[int]) -> bytes:
82-
if len(x) > 0:
83-
return bytes(x)
84-
return b''
81+
# returns empty string for values out of the valid Unicode range
8582
def uchr(x:int) -> str:
86-
return chr(x)
83+
try:
84+
return chr(x)
85+
except ValueError:
86+
return ''
87+
8788
def decodeLocal(x:Optional[Any]) -> Optional[str]:
8889
if x is not None:
89-
return codecs.unicode_escape_decode(x)[0]
90+
try:
91+
return codecs.unicode_escape_decode(x)[0]
92+
except Exception: # pylint: disable=broad-except
93+
return None
9094
return None
9195
def decodeLocalStrict(x:Optional[Any], default:str = '') -> str:
9296
if x is None:
9397
return default
94-
return codecs.unicode_escape_decode(x)[0]
98+
try:
99+
return codecs.unicode_escape_decode(x)[0]
100+
except Exception: # pylint: disable=broad-except
101+
return default
95102
def encodeLocal(x:Optional[Any]) -> Optional[str]:
96103
if x is not None:
97-
return codecs.unicode_escape_encode(str(x))[0].decode('utf8')
104+
try:
105+
return codecs.unicode_escape_encode(str(x))[0].decode('utf8')
106+
except Exception: # pylint: disable=broad-except
107+
return None
98108
return None
99109
def encodeLocalStrict(x:Optional[Any], default:str = '') -> str:
100110
if x is None:
101111
return default
102-
return codecs.unicode_escape_encode(str(x))[0].decode('utf8')
112+
try:
113+
return codecs.unicode_escape_encode(str(x))[0].decode('utf8')
114+
except Exception: # pylint: disable=broad-except
115+
return default
103116
def hex2int(h1:int, h2:Optional[int] = None) -> int:
104117
if h2 is not None:
105118
return int(h1*256 + h2)
106119
return int(h1)
120+
121+
# str2cmd converts string to bytes ignoring all non-ascii characters. Result to be used for low-level device communication.
107122
def str2cmd(s:str) -> bytes:
108-
return bytes(s,'ascii')
123+
return s.encode('ascii', errors='ignore')
109124
def cmd2str(c:bytes) -> str:
110125
return str(c,'latin1')
111126
def s2a(s:str) -> str:
112-
return s.encode('ascii','ignore').decode('ascii')
127+
return str2cmd(s).decode('ascii')
113128

114129
# returns True if x is not None, not NaN and not the error value -1 or 0
115130
def is_proper_temp(x:Union[None, int, float]) -> bool:
116-
return x is not None and not numpy.isnan(x) and isinstance(x, (int, float)) and x not in [0, -1]
131+
return x is not None and not numpy.isnan(x) and isinstance(x, (int, float)) and x not in [0, -1, float('-inf'), float('inf')]
117132

118-
# returns the prefix of length ll of s and adds eclipse
133+
# returns the prefix of length ll-1 of s and adds Unicode ellipsis character
134+
# the length of the resulting string is max(1, ll, len(s))
119135
def abbrevString(s:str, ll:int) -> str:
120136
if len(s) > ll:
121-
return f'{s[:ll-1]}...'
137+
return f'{s[:max(0,ll-1)]}\u2026'
122138
return s
123139

124140
# used to convert time from int seconds to string (like in the LCD clock timer). input int, output string xx:xx
@@ -137,13 +153,13 @@ def stringfromseconds(seconds_raw:float, leadingzero:bool = True) -> str:
137153
return f'-{d:02d}:{m:02d}'
138154
return f'-{d:d}:{m:02d}'
139155

140-
#Converts a string into a seconds integer. Use for example to interpret times from Roaster Properties Dlg inputs
141-
#accepted formats: "00:00","-00:00"
142-
# returns -1 on invalid inputs
156+
# Converts a string into a seconds integer. Use for example to interpret times from Roaster Properties Dlg inputs
157+
# accepted formats: "00:00","-00:00"
158+
# raises ValueError or IndexError on invalid inputs
143159
def stringtoseconds(string:str) -> int:
144160
timeparts = string.split(':')
145161
if len(timeparts) != 2:
146-
return -1
162+
raise ValueError(f"the string '{string}' is not a properly formatted time string of format xx:xx or -xx:xx")
147163
if timeparts[0][0] != '-': #if number is positive
148164
seconds = int(timeparts[1])
149165
seconds += int(timeparts[0])*60
@@ -200,14 +216,14 @@ def RoRfromFtoC(FRoR:Optional[float]) -> Optional[float]:
200216
return FRoR
201217
return RoRfromFtoCstrict(FRoR)
202218

203-
def convertRoR(r:Optional[float], source_unit:str, target_unit:str) -> Optional[float]:
219+
def convertRoR(r:Optional[float], source_unit:Literal['C', 'F'], target_unit:Literal['C', 'F']) -> Optional[float]:
204220
if source_unit == target_unit:
205221
return r
206222
if source_unit == 'C':
207223
return RoRfromCtoF(r)
208224
return RoRfromFtoC(r)
209225

210-
def convertRoRstrict(r:float, source_unit:str, target_unit:str) -> float:
226+
def convertRoRstrict(r:float, source_unit:Literal['C', 'F'], target_unit:Literal['C', 'F']) -> float:
211227
if source_unit == target_unit:
212228
return r
213229
if source_unit == 'C':
@@ -237,6 +253,7 @@ def path2url(path:str) -> str:
237253
# remaining artifacts from Qt4/5 compatibility layer:
238254
# note: those conversion functions are sometimes called with string arguments
239255
# thus a simple int(round(s)) won't work and a int(round(float(s))) needs to be applied
256+
# float('inf') and float('-inf') cannot be converted to integer and are mapped to 0
240257
def toInt(x:Optional[Union[int,str,float]]) -> int:
241258
if x is None:
242259
return 0
@@ -589,7 +606,8 @@ def scaleFloat2String(num:Union[float,str]) -> str:
589606

590607
# for use in widgets that expects a double via a self.createCLocalDoubleValidator that accepts both,
591608
# one dot and several commas. If there is no dot, the last comma is interpreted as decimal separator and the others removed
592-
# if there is a dot, the last one is used as a decimal separator and all other comma and dots are removed
609+
# if there is a dot, the last one is used as a decimal separator and all other comma and dots are removed.
610+
# Trailing dots are removed as well.
593611
def comma2dot(s:str) -> str:
594612
s = s.strip()
595613
last_dot = s.rfind('.')
@@ -617,6 +635,7 @@ def comma2dot(s:str) -> str:
617635
volume_units:Final[Tuple[str,str,str,str,str,str]] = ('l','gal','qt','pt','cup','ml')
618636

619637
def weightVolumeDigits(v:float) -> int:
638+
v = abs(v)
620639
if v >= 1000:
621640
return 1
622641
if v >= 100:
@@ -635,8 +654,9 @@ def float2floatNone(f:Optional[float], n:int=1) -> Optional[float]:
635654
return None
636655
return float2float(f,n)
637656

638-
# the int n specifies the number of digits
657+
# the int n>=0 specifies the number of digits
639658
def float2float(f:float, n:int=1) -> float:
659+
n = max(n, 0)
640660
f = float(f)
641661
if n==0:
642662
if math.isnan(f):
@@ -656,7 +676,9 @@ def convertWeight(v:float, i:int, o:int) -> float:
656676
[453.591999, 0.45359237, 1., 16.], # lb
657677
[28.3495, 0.0283495, 0.0625, 1.] # oz
658678
]
659-
return v*convtable[i][o]
679+
if 0 <= i < len(convtable) and 0 <= o < len(convtable):
680+
return v*convtable[i][o]
681+
raise IndexError(f'index error in convertWeight({v},{i},{o})')
660682

661683
# i/o: 0:l (liter), 1:gal (gallons US), 2:qt, 3:pt, 4:cup, 5:cm^3/ml
662684
def convertVolume(v:float, i:int, o:int) -> float:
@@ -669,7 +691,9 @@ def convertVolume(v:float, i:int, o:int) -> float:
669691
[0.23658823, 0.0625, 0.25, 0.5, 1., 236.5882365 ], # cup
670692
[0.001, 2.6417205e-4, 1.05668821e-3, 2.11337641e-3, 4.2267528e-3, 1. ] # cm^3
671693
]
672-
return v*convtable[i][o]
694+
if 0 <= i < len(convtable) and 0 <= o < len(convtable):
695+
return v*convtable[i][o]
696+
raise IndexError(f'index error in convertVolume({v},{i},{o})')
673697

674698

675699
# takes a weight, its weight unit index, and a weight unit target index (decides over metric vs imperial)
@@ -795,7 +819,7 @@ def is_float_list(xs: List[Any]) -> TypeGuard[List[float]]:
795819
# locale tools
796820

797821
def right_to_left(locale:str) -> bool:
798-
return locale in {'ar', 'fa', 'he'}
822+
return locale.lower() in {'ar', 'fa', 'he'}
799823

800824
#def locale2full_local(locale:str) -> str:
801825
# locale_map:Dict[str,str] = {

src/coverage.svg

Lines changed: 2 additions & 2 deletions
Loading

src/plus/weight.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ class WeightItem:
6262
# For roasted blend items this is always just the one element list [(1, '')]
6363
position:str # the position string (like "2/5" for 2nd of 5 batches)
6464
weight:float # batch size (target weight for GreenWeightItem) in kg
65-
weight_estimate: float # expected yield in kg
65+
weight_estimate: float # expected yield in kg (only valid for RoastedWeightItems!)
6666
weight_unit_idx:int # the weight unit, one of (0:'g', 1:'kg', 2:'lb', 3:'oz')
6767
callback:Callable[[str, float], None] # the function to be called with id:str and weight (in kg) on completion
6868

@@ -75,15 +75,6 @@ class RoastedWeightItem(WeightItem):
7575
...
7676

7777

78-
# Consider
79-
#. if green: target weight (counts down)
80-
#. if green: bean or blend name
81-
#. if green: container recognized (or not) indicating container name
82-
#. if roasted: batch number + roast name + green weight + weight loss
83-
#. registered weight (so far)
84-
85-
86-
8778
class Display:
8879

8980
__slots__ = [ #'active'

0 commit comments

Comments
 (0)