0
\$\begingroup\$

Problem Statement

From the National Institute of Standards and Technology's Office of Weights and Measures, one can find a handy list of all the metric prefixes: e.g.

Purpose Name Symbol Factor Name
larger quantities or whole units quetta Q $$10^{30}$$ nonillion
ronna R $$10^{27}$$ octillion
yotta Y $$10^{24}$$ septillion
... ... ... ...
hecto Example: hectare h $$10^{2}$$ hundred
deka Example: dekameter da $$10^{1}$$ ten
$$10^{o}$$ one
deci Example: decimeter da $$10^{-1}$$ tenth
centi Example: centigram h $$10^{-2}$$ hundredth
... ... ... ...
yocto Example: yoctosecond y $$10^{-24}$$ septillionth
ronto r $$10^{-27}$$ octillionth
smaller quantities or sub units quecto q $$10^{-30}$$ nonillionth

Oh how nice it would be to have a class like unit which we could subclass and use as follows:

class second(unit): 
    name = 'second'

s1 = second(1)
s1, s1.kilo, s1.milli, s1.to(3), float(s1.to(3))
(1.0 S, 0.001 KS, 1000.0 mS, 0.001 KS, 0.001)

ah so easy to convert between and even have clean formatting. What follows is my solution to make the above snippet possible.

Code Review Requests

So why am I posting this on CodeReview, or more accurately, what aspects of my solutions code do I want reviewed?

Before anyone starts typing in the comments, yes this is likely over-engineered. This style of solution was chosen as my approach because:

  • I am learning about meta classes (good chance to practice),
  • I wanted a subclass-able solution,
  • I wanted unit to work as if it is just a number with unit conversion magic baked in,
  • I wanted unit to support different base units e.g. a kilogram is 1,000 grams (10^3) whereas a kilobyte is 1,024 bytes (2^10).

So while you are reading my solution, I would appreciate you keeping this in mind :) Thank you in advance for your time and feedback!

Solution

The solution occurs in 3 parts:

  1. signed integers (sint) which is used for representing and formatting the sign of the exponent.
  2. factors (fact) which represents a "factor" (or metric prefix e.g. kilo)
  3. unit which is the unit class

sint

from numbers import Number
from typing import Any, Tuple,Union, Callable, Literal, Optional, TypeAlias, ForwardRef, TypeGuard, NamedTuple, get_args

# -----------------------------------------------------------------
# type aliases
# -----------------------------------------------------------------
NumStr: TypeAlias = Union[Number, str]
SignInt: TypeAlias = Literal[1, 0, -1]
SignChr: TypeAlias = Literal['P', '', 'N']
SignSym: TypeAlias = Literal['+', '', '-']

NumStrQ: TypeAlias = Optional[NumStr]
SignIntQ: TypeAlias = Optional[SignInt]
SignChrQ: TypeAlias = Optional[SignChr]
SignSymQ: TypeAlias = Optional[SignSym]

ExpoChr: TypeAlias = Literal['^', '**', 'E']
ExpoChrQ: TypeAlias = Optional[ExpoChr]

sint: TypeAlias = ForwardRef('sint')

# -----------------------------------------------------------------
# typeguards
# -----------------------------------------------------------------
def isint(v: Any) -> TypeGuard[int]: return isinstance(v, int)
def isstr(v: Any) -> TypeGuard[str]: return isinstance(v, str)
def isnum(v: Any) -> TypeGuard[Number]: return isinstance(v, Number)
def isnumstr(v: Any) -> TypeGuard[NumStr]: return isinstance(v, NumStr)


def issignint(v: Any) -> TypeGuard[SignInt]: return v in {1, 0, -1}       # isinstance(v, SignInt)
def issignchr(v: Any) -> TypeGuard[SignChr]: return v in {'P', 'N', ''}   # isinstance(v, SignChr)
def issignsym(v: Any) -> TypeGuard[SignSym]: return v in {'+', '-', ''}   # isinstance(v, SignSym)
def isexpochr(v: Any) -> TypeGuard[ExpoChr]: return v in {'^', '**', 'E'} # isinstance(v, ExpoChr)

def ispos(n: Number) -> bool: return isnum(n) and n > 0
def isneg(n: Number) -> bool: return isnum(n) and n < 0
def iszer(n: Number) -> bool: return isnum(n) and n == 0


# -----------------------------------------------------------------
# sign
# -----------------------------------------------------------------
class sign(NamedTuple):
    '''Represent the numerical and symbolic information of a sign.'''
    int: SignInt = 0
    chr: SignChr = ''
    sym: SignSym = ''
    syn: set = set()

    @property
    def set(self) -> set: return set({str(self.int), self.chr, self.sym}) | self.syn
    def issyn(self, s: str) -> bool: return s in self.set

P = sign( 1, 'P', '+', set({'+', 'pos', 'positive'}))
N = sign(-1, 'N', '-', set({'-', 'neg', 'negative'}))
Z = sign( 0, '',   '', set())

sign.P = P
sign.N = N
sign.Z = Z



# -----------------------------------------------------------------
# constants
# -----------------------------------------------------------------
e: str = 'e'
F: str = 'F'

BASE: int = 10

SCHRS: str = ''.join(get_args(SignChr))
SSYMS: str = ''.join(get_args(SignSym))
ECHRS: str = ''.join(get_args(ExpoChr))


# -----------------------------------------------------------------
# string patterns
# -----------------------------------------------------------------
import re
from string import Template

ftmpl = Template(f'{F}$expo$sign')
btmpl = Template(f'$base$echr$sign$expo')
etmpl = Template(f'{e}$sign$expo')

fpttn = re.compile(rf'{F}(?P<expo>\d+)(?P<sign>[{re.escape(SCHRS)}]?)')
bpttn = re.compile(rf'(?P<base>\d*)\s*[{re.escape(ECHRS)}]\s*(?P<sign>[{re.escape(SSYMS)}]?)(?P<expo>\d+)')
epttn = re.compile(rf'{e}(?P<sign>[{re.escape(SSYMS)}]?)(?P<expo>\d+)')


# -----------------------------------------------------------------
# utilities
# -----------------------------------------------------------------
def psyn(s: str) -> bool: return P.issyn(s)
def nsyn(s: str) -> bool: return N.issyn(s)
def zsyn(s: str) -> bool: return Z.issyn(s)

def tern2x(
    val: Any, boola: Callable = None, boolb: Callable = None, 
    yesa: Any = None, yesb: Any = None, noab: Any = None
) -> bool:
    return yesa if boola(val) else yesb if boolb(val) else noab

def any2signint(val: Any, posfn: Callable = ispos, negfn: Callable = isneg) -> SignInt: 
    return tern2x(val, posfn, negfn, P.int, N.int, Z.int)

def num2signint(s: Number) -> SignInt: return any2signint(s, ispos, isneg)
def str2signint(s: str) -> SignInt:
    try: return num2signint(int(s))
    except: return any2signint(s, psyn, nsyn)

def numstr2signint(s: NumStrQ) -> SignInt: return str2signint(s) if isstr(s) else num2signint(s) if isnum(s) else Z.int

def tosignint(s: Any) -> SignIntQ: return numstr2signint(s) if isnumstr(s) else None
def matchsign(val: Any, pos: Any = P.int, neg: Any = N.int, nil: Any = Z.int, default: Any = None) -> Any:
    match tosignint(val):
        case  1: return pos
        case -1: return neg
        case  0: return nil
        case  _: return default

def toint(s: NumStr) -> SignInt: return matchsign(s, P.int, N.int, Z.int)
def tochr(s: NumStr) -> SignChr: return matchsign(s, P.chr, N.chr, Z.chr)
def tosym(s: NumStr) -> SignSym: return matchsign(s, P.sym, N.sym, Z.sym)
def tosyn(s: NumStr) -> set: return matchsign(s, P.syn, N.syn, Z.syn)
def toset(s: NumStr) -> set: return matchsign(s, P.set, N.set, Z.set)
def topow(base: int, expo: int, sign: NumStrQ = None) -> Number: return base ** (expo * tosignint(sign or expo))

def tosign(s: NumStr, force: bool = False) -> Optional[sign]: return matchsign(s, P, N, Z, None if force else Z)
def totuple(s: NumStr) -> tuple: return tuple(tosign(s))


# -----------------------------------------------------------------
# string utilities
# -----------------------------------------------------------------
def prep_strs(
    expo: Number = 0, sign: sint = None, base: Number = BASE, 
    echr: ExpoChr = '^', showsign: bool = True, showbase: bool = True
) -> Tuple[int, str, SignSymQ, SignChrQ, ExpoChrQ]:
    sign = tosign(sign or expo, force = True)
    bstr = str(base) if showbase else ''
    sstr = sign.sym if showsign else ''
    schr = sign.chr
    echr = echr if isexpochr(echr) else '^'
    estr = abs(expo)
    return bstr, sstr, schr, estr, echr


# -----------------------------------------------------------------
# string representations
# -----------------------------------------------------------------
def make_fstr(
    expo: Number = 0, sign: sint = None, base: Number = BASE, 
    echr: ExpoChr = '^', showsign: bool = True, showbase: bool = True
) -> str:
    bstr, sstr, schr, estr, echr = prep_strs(expo, sign, base, echr, showsign, showbase)
    return ftmpl.substitute(expo=estr, sign=schr)

def make_bstr(
    expo: Number = 0, sign: sint = None, base: Number = BASE, 
    echr: ExpoChr = '^', showsign: bool = True, showbase: bool = True
) -> str:
    bstr, sstr, schr, estr, echr = prep_strs(expo, sign, base, echr, showsign, showbase)
    return btmpl.substitute(base=bstr, sign=sstr, echr=echr, expo=estr)

def make_estr(
    expo: Number = 0, sign: sint = None, base: Number = BASE, 
    echr: ExpoChr = '^', showsign: bool = True, showbase: bool = True
) -> str:
    bstr, sstr, schr, estr, echr = prep_strs(expo, sign, base, echr, showsign, showbase)
    return etmpl.substitute(sign=sstr, expo=estr)

def from_fstr(s: str, base: int = BASE) -> (Number, SignIntQ, Number):
    match = fpttn.match(s)
    if match:
        expo = int(match.group('expo'))
        sign = toint(match.group('sign'))
        return expo, sign, base
    raise ValueError(f"String '{s}' doesn't match factor format")

def from_bstr(s: str, base: int = BASE) -> (Number, SignIntQ, Number):
    match = bpttn.match(s)
    if match:
        base = int(match.group('base')) if match.group('base') else base
        expo = int(match.group('expo'))
        sign = toint(match.group('sign') + '1')
        return expo, sign, base
    raise ValueError(f"String '{s}' doesn't match base format")

def from_estr(s: str, base: int = BASE) -> (Number, Number, SignIntQ):
    match = epttn.match(s)
    if match:
        expo = int(match.group('expo'))
        sign = toint(match.group('sign') + '1')
        return expo, sign, base
    raise ValueError(f"String '{s}' doesn't match exponent format")



# -----------------------------------------------------------------
# errors
# -----------------------------------------------------------------
class SignIntError(ValueError):
    def __init__(self, val: Any):
        msg = f'Invalid sint: {val}. Must be one of {P.int, Z.int, N.int}, a string integer, or {P.syn | Z.syn | N.syn}.'
        super().__init__(msg)



# -----------------------------------------------------------------
# sint
# NOTE: 
# 1. sint(base) is intentional as we abuse init_subclass to initialize the singleton signs
# 2. the redeclaration of all the utility methods is also intentional so that they can be used independently from the `sint` class
# -----------------------------------------------------------------
class base:    
    @classmethod
    def ispos(cls, n: Number) -> bool: return ispos(n)
    @classmethod
    def isneg(cls, n: Number) -> bool: return isneg(n)
    @classmethod
    def iszer(cls, n: Number) -> bool: return iszer(n)

    @classmethod
    def psyn(cls, s: str) -> bool: return psyn(s)
    @classmethod
    def nsyn(cls, s: str) -> bool: return nsyn(s)
    @classmethod
    def zsyn(cls, s: str) -> bool: return zsyn(s)

    @classmethod
    def any2signint(cls, val: Any, posfn = ispos, negfn = isneg) -> SignInt: return any2signint(val, posfn, negfn)
    @classmethod
    def num2signint(cls, s: Number) -> SignInt: return num2signint(s)
    @classmethod
    def str2signint(cls, s: str) -> SignInt: return str2signint(s)
    @classmethod
    def numstr2signint(cls, s: NumStrQ) -> SignInt: return numstr2signint(s)
    @classmethod
    def tosignint(cls, s: Any) -> SignIntQ: return tosignint(s)

    @classmethod
    def match(cls, val: Any, pos: Any = P.int, neg: Any = N.int, nil: Any = Z.int, default: Any = None) -> Any:
        return matchsign(val, pos, neg, nil, default)    
    
    @classmethod
    def toint(cls, s: NumStr) -> SignInt: return toint(s)
    @classmethod
    def tochr(cls, s: NumStr) -> SignChr: return tochr(s)
    @classmethod
    def tosym(cls, s: NumStr) -> SignSym: return tosym(s)
    @classmethod
    def tosyn(cls, s: NumStr) -> SignSym: return tosyn(s)
    @classmethod
    def toset(cls, s: NumStr) -> SignSym: return toset(s)

    @classmethod
    def topow(cls, base: int, expo: int, sign: Optional[NumStr] = None) -> Number: return topow(base, expo, sign)    

    @classmethod
    def tosign(cls, s: NumStr, force: bool = False) -> sign: return tosign(s, force)
    @classmethod
    def totuple(cls, s: NumStr) -> tuple: return totuple(s)


    def pow(self, base: int, expo: int) -> Number: return self.topow(base, expo, self)
    def eval(self, pos: Any = 1, neg: Any = -1, nil: Any = 0, default: Any = None) -> Any:
        return self.match(self, pos, neg, nil, default)
    def tuple(self) -> tuple: return self.totuple(self)
    def issyn(self, s: str) -> bool: return s in self.set

    @classmethod
    def to(cls, s: NumStr) -> Optional['sint']:
        return cls(s) if isnumstr(s) else None
    
    @property
    def sym(self) -> str: return self.tosym(self)    
    @property
    def chr(self) -> str: return self.tochr(self)
    @property
    def syn(self) -> set: return self.tosyn(self)
    @property
    def set(self) -> set: return self.toset(self)

    @classmethod
    def from_bstr(cls, s: str) -> (Number, SignIntQ, Number): return from_bstr(s, base = BASE)
    @classmethod
    def from_estr(cls, s: str) -> (Number, SignIntQ, Number): return from_estr(s, base = BASE)
    @classmethod
    def from_fstr(cls, s: str) -> (Number, SignIntQ, Number): return from_fstr(s, base = BASE)

    @classmethod
    def make_bstr(cls, 
        expo: Number = 0, sign: Optional['sint'] = None, base: Number = BASE, 
        echr: ExpoChr = '^', showsign: bool = True, showbase: bool = True
    ) -> str: 
        return make_bstr(expo, sign, base, echr, showsign, showbase)
    @classmethod
    def make_fstr(cls, 
        expo: Number = 0, sign: Optional['sint'] = None, base: Number = BASE, 
        echr: ExpoChr = '^', showsign: bool = True, showbase: bool = True
    ) -> str: 
        return make_fstr(expo, sign, base, echr, showsign, showbase)
    @classmethod
    def make_estr(cls, 
        expo: Number = 0, sign: Optional['sint'] = None, base: Number = BASE, 
        echr: ExpoChr = '^', showsign: bool = True, showbase: bool = True
    ) -> str: 
        return make_estr(expo, sign, base, echr, showsign, showbase)
    
    def __new__(cls, val: NumStr, *args, **kwargs):
        val = cls.tosignint(val)
        singleton = kwargs.get('__singleton__', False)
        if not issignint(val): raise SignIntError(val)        
        if not singleton: return cls.match(val, cls.P, cls.N, cls.Z)
        return super().__new__(cls, val)
    
    @classmethod
    def __initsigns__(cls):
        cls.P = cls( 1, __singleton__=True)
        cls.N = cls(-1, __singleton__=True)
        cls.Z = cls( 0, __singleton__=True)
        return cls
    
    def __init_subclass__(cls, *args, **kwargs):
        cls.__initsigns__()

class sint(base, int):
    '''Derived class from int to represent -1, 0, 1 as sint.

    Methods
    -------
    eval:
        Evaluate the sint object.
    match:
        Class method to match sints.
    tosym:
        Convert sint to its symbolic representation.
    tostr:
        Convert sint to its string representation.
    topow:
        Calculate the power using sint.
    sym:
        Property to get the symbolic representation.
    str:
        Property to get the string representation.
    pow:
        Calculate the power using sint.
    to:
        Class method to construct sint.
    '''
    ...

While that may be a lot of overhead for just '+', '-' and '' for (1, -1, 0) it does work nicely:

>>> expos = (0, 1, +6, +10, -30, -15)
>>> fstrs = list(map(make_fstr, expos))
>>> bstrs = list(map(make_bstr, expos))
>>> estrs = list(map(make_estr, expos))
>>> (fstrs, bstrs, estrs,)

(
    ('F0', 'F1P', 'F10P', 'F30N', 'F15N'),
    ('10^0', '10^+1', '10^+10', '10^-30', '10^-15'),
    ('e0', 'e+1', 'e+10', 'e-30', 'e-15'),
)


>>> sint(2), sint(0), sint(-2), sint('2'), sint('0'), sint('-2'), sint('+'), sint('0'), sint('-')

(1, 0, -1, 1, 0, -1, 1, 0, -1)

>>> sint(2).chr, sint(2).sym, sint(0).chr, sint(0).sym, sint.tochr(2)
('P', '+', '', '', 'P')

fact

from typing import Any, Literal, Optional, TypeAlias, ClassVar

from enum import StrEnum
from numbers import Number
from sint import sint


DOT = '.'
CAROT = '^'
STARSTAR = '**'

FactQ: TypeAlias = Optional['fact']
FactRepr: TypeAlias = Literal['abrv', 'name', 'symb']

BASE = 10
SIGNS = (sint.P, sint.N)
EXPOS = (1, 2, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30)

class ExpoChr(StrEnum):
    carot = CAROT    # '^'
    dstar = STARSTAR # '**'
    @classmethod
    def to(cls, v: str): return cls(v) if cls.isin(v) else cls.carot
    @classmethod
    def isin(cls, v: str): return v in list(map(str, cls._member_map_.values()))

class FactorError(KeyError):
    def __init__(self, key: str):  
        super().__init__(f'No SI unit associated with key: {key}')

def getbval(bases: tuple, attr: str, default = None) -> Optional[Any]:
    '''Get the first attribute value found in the base classes'''
    for base in bases:
        if hasattr(base, attr): return getattr(base, attr)
    return default

def getmval(bases: tuple, cls_dict: dict, attr: str, default = None) -> Optional[Any]:
    '''Get the first attribute either from the `cls_dict` or in any of the base classes'''
    cval = cls_dict.get(attr)
    bval = getbval(bases, attr, default)
    return cval if cval is not None else bval

def dcsrct(d: dict, *args, __flbk: Any = None) -> tuple:
    '''Deconstruct a dictionary
    
    Examples
    --------
    >>> b, d = dcsrct(dict(a=1, b=2, c=3, d=4), 'b', 'd')
    >>> b, d
    (2, 4)
    '''
    if __flbk is None: __flbk = tuple(None for _ in range(len(args)))
    return tuple(d.get(attr, dflt) for attr, dflt in zip(args, __flbk))


class fmeta(type):      
    def __new__(cls, name, bases, cls_dict):              
        attrs = ('sign', 'expo')
        vdict = dict((attr, getmval(bases, cls_dict, attr)) for attr in attrs)
        sign, expo = dcsrct(vdict, 'sign', 'expo')        
        cls_dict['ekey'] = sign * expo if not any((sign is None, expo is None)) else None
        
        fcls = super().__new__(cls, name, bases, cls_dict)
        return fcls
    
class fact(float, metaclass=fmeta):
    # ------------------------------------------------------------------------------------
    # class variables & methods
    # ------------------------------------------------------------------------------------
    name: ClassVar[str]
    abrv: ClassVar[str]
    symb: ClassVar[str]
    expo: ClassVar[int] = 0
    sign: ClassVar[sint] = None
    ekey: ClassVar[int] # NOTE: sign * expo, defined in `fmeta`

    @classmethod
    def clsdef(cls, attr: str, default: Any = None, **kwargs):
        '''Get the `attr` from `kwargs`, the class, or the `default`, whichever is found first'''
        val = kwargs.get(attr, getattr(cls, attr, default))
        if attr == 'echr': val = ExpoChr.to(val)
        if attr == 'ekey': val = cls.sign * cls.expo
        return default if val is None else val
    
    def __new__(cls, base: Optional[int] = BASE, **kwargs) -> 'fact':
        '''Set the float to be the powered value of the base and the exponent'''
        numb = sint.topow(base, cls.expo, cls.sign)
        obj = float.__new__(cls, numb)
        obj.base = base        
        return obj

    def __init__(self, *args, **kwargs) -> None:
        for attr in ('echr', 'showsign', 'showbase', 'showefmt'):
            setattr(self, attr, self.clsdef(attr, **kwargs))

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        cls.register(cls)   

    @classmethod
    def iseq(cls, v) -> bool:
        if cls == v: return True
        keys = ('name', 'abrv', 'symb', 'ekey',)
        if any(v == getattr(cls, attr, None) for attr in keys): return True
        return False

    @classmethod
    def get(cls, v) -> Optional['fact']:
        for f in cls.facts:
            if f.iseq(v): return f
        return None
    
    @classmethod
    def bump(cls, fcls: 'fact', bump: int = 0) -> 'fact':
        exps = sorted({f.expo for f in cls.facts}) # [0, 1, 2, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30]
        sign, expo = fcls.sign if fcls.sign != sint.Z else 1, fcls.expo
        
        # Get current index of the exponent
        eidx = exps.index(expo)
        
        nidx = ((sign * eidx) + bump)
        if nidx < 0: sign, nidx = -sign, abs(nidx) # Adjust negative indices

        # Handle sign change when crossing boundaries e.g. `<= -30` or `>= +30`
        if len(exps) <= abs(nidx): return fact.get(sign * exps[len(exps) - 1]) 
        return cls.get(sign * exps[nidx]) # Get the new exponent
    
    @classmethod
    def list(cls, attr: str = 'abrv') -> list:
        return [getattr(f, attr, None) for f in cls.facts]
        
    @classmethod
    def convert(cls, val: Number, to: 'fact') -> float:
        fcls = cls.get(to)
        if not fcls: raise FactorError(to)
        return val * float(fcls(getattr(val, 'base', cls.base)))
    
    # ------------------------------------------------------------------------------------
    # class / subclass shared variables & methods
    # ------------------------------------------------------------------------------------
    facts: ClassVar[set['fact']] = set()
    @classmethod
    def register(cls, f: 'fact'):
        rattrs = ('name', 'expo', 'sign') # required attributes to be a valid `fact` subclass
        hasall = all(hasattr(f, attr) for attr in rattrs)
        if not hasall and not isinstance(f, cls): return
        cls.facts.add(f)

    # ------------------------------------------------------------------------------------
    # instant variables & methods
    # ------------------------------------------------------------------------------------
    base: Optional[int] = BASE
    echr: ExpoChr  = ExpoChr.carot
    showsign: bool = True
    showbase: bool = False    
    showefmt: bool = True

    @property
    def fstr(self):
        '''Returns the exponential format of the factor e.g. F3N'''
        return sint.make_fstr(self.expo, self.sign, self.base, self.echr, self.showsign, self.showbase)        
    @property
    def bstr(self):
        '''Returns the base format of the factor e.g. `10^-3`'''
        return sint.make_bstr(self.expo, self.sign, self.base, self.echr, self.showsign, self.showbase)        
    @property
    def efmt(self):
        '''Returns the exponential format of the factor e.g. 10-3'''
        return sint.make_estr(self.expo, self.sign, self.base, self.echr, self.showsign, self.showbase)    

    def ispos(self): return sint.to(self.sign) == sint.P
    def isneg(self): return sint.to(self.sign) == sint.N
    def __str__(self) -> str:  return f'{self.efmt}' if self.showefmt else f'{self.fstr}'
    def __repr__(self) -> str: return str(self)

# fact subclasses for positive (`pfact`, e.g. e+<n>), negative (`nfact`, e.g. e-<n>), and the "base" factor (`zfact` e.g. e0)
class zfact(fact): abrv, symb, expo, sign =  None, None, 0, sint.Z
class pfact(fact): sign = sint.P
class nfact(fact): sign = sint.N

# zfact subclass for the "base" factor (e0)
class one(zfact): name = 'one'

# pfact subclasses
class decka(pfact): name, abrv, symb, expo = 'ten', 'decka', 'D', 1
class hecto(pfact): name, abrv, symb, expo = 'hundred', 'hecto', 'H', 2
class kilo(pfact): name, abrv, symb, expo = 'thousand', 'kilo', 'K', 3
class mega(pfact): name, abrv, symb, expo = 'million', 'mega', 'M', 6
class giga(pfact): name, abrv, symb, expo = 'billion', 'giga', 'G', 9
class tera(pfact): name, abrv, symb, expo = 'trillion', 'tera', 'T', 12
class peta(pfact): name, abrv, symb, expo = 'quadrillion', 'peta', 'P', 15
class exa(pfact): name, abrv, symb, expo = 'quintillion', 'exa', 'E', 18
class zetta(pfact): name, abrv, symb, expo = 'sextillion', 'zetta', 'Z', 21
class yotta(pfact): name, abrv, symb, expo = 'septillion', 'yotta', 'Y', 24
class ronna(pfact): name, abrv, symb, expo = 'octillion', 'ronna', 'R', 27
class quetta(pfact): name, abrv, symb, expo = 'nonillion', 'quetta', 'Q', 30

# nfact subclasses
class deci(nfact): name, abrv, symb, expo = 'tenth', 'deci', 'd', 1
class centi(nfact): name, abrv, symb, expo = 'hundredth', 'centi', 'c', 2
class milli(nfact): name, abrv, symb, expo = 'thousandth', 'milli', 'm', 3
class micro(nfact): name, abrv, symb, expo = 'millionth', 'micro', 'u', 6
class nano(nfact): name, abrv, symb, expo = 'billionth', 'nano', 'n', 9
class pico(nfact): name, abrv, symb, expo = 'trillionth', 'pico', 'p', 12
class femto(nfact): name, abrv, symb, expo = 'quadrillionth', 'femto', 'f', 15
class atto(nfact): name, abrv, symb, expo = 'quintillionth', 'atto', 'a', 18
class zepto(nfact): name, abrv, symb, expo = 'sextillionth', 'zepto', 'z', 21
class yocto(nfact): name, abrv, symb, expo = 'septillionth', 'yocto', 'y', 24
class ronto(nfact): name, abrv, symb, expo = 'octillionth', 'ronto', 'r', 27
class quecto(nfact): name, abrv, symb, expo = 'nonillionth', 'quecto', 'q', 30

Still a lot of work but it also does work nicely. While each fact (factor) has base: ClassVar[int] = 10, base is actually an instance variable.

>>> float(kilo()), float(kilo(base=2)), float(kilo(base=5)), float(kilo(base=10))

(1000.0, 8.0, 125.0, 1000.0)

In case that behavior is not obvious fact is really just a named and explicilty signed exponent:

>>> kb = kilo(base=2)
>>> kb.abrv, kb.base, kb.expo, kb.sign, kb.ekey, float(kb)

('kilo', 2, 3, 1, 3, 8.0)

Each fact uses efmt for its representation by default efmt is True, but can be turned off by setting efmt to False.

>>> (
    (decka(), hecto(), kilo(), mega(), giga(), tera()),
    (decka(showefmt=False), hecto(showefmt=False), kilo(showefmt=False), mega(showefmt=False), giga(showefmt=False), tera(showefmt=False))
)

((e+1, e+2, e+3, e+6, e+9, e+12), (F1P, F2P, F3P, F6P, F9P, F12P))

Actually we have three formats to work with:

>>> kilo().fstr, kilo(showbase=True).bstr, kilo(showbase=True).efmt

('F3P', '10^+3', 'e+3')

So we can finally proceed to the unit class

unit

def pluralize(word: str, num: float) -> str:
    '''Convert a word to its plural form based on a numeric value.

    Parameters
    ----------
    word : str
        Word to potentially pluralize.
    num : float
        Numeric value to determine whether to pluralize.

    Returns
    -------
    str
        Original word or its plural form depending on the numeric value.

    Notes
    -----
    Returns the word in its original form if the number is 1. Otherwise, converts to plural.
    '''
    if num != 1:
        if word.endswith('y'): return word[:-1] + 'ies'
        else: return word + 's'
    return word

def fmtnumb(n: Number, digits: int = 3) -> str:
    '''Convert a number to a string representation with specified significant digits.

    Parameters
    ----------
    n : Number
        Number to be converted to string.
    digits : int, default=3
        Number of significant digits.

    Returns
    -------
    str
        String representation of the number with the specified significant digits.

    Notes
    -----
    The conversion can also be achieved using the string format mini-language:
    `r = f'{n:.{digits}f}'`.
    '''
    r = str(round(n, digits))
    i, _, d = r.partition(DOT)
    if set(d) != set('0'): return str(r)
    return i if _ == '' else f'{i}.0'

def fmtunit(
    org: float,                   # float(unit(...))
    flt: Optional[float] = None,  # float(unit(...).flt)
    unt: Optional['unit'] = None, # unit(...) # instance of a unit class
    fct: Optional['fact'] = None,    
    shownumb: Optional[bool] = True,
    abrvunit: Optional[bool] = False,
    factrepr: Optional[Literal['abrv', 'name', 'symb']] = 'abrv',
    ndig: Optional[int] = 3, 
    unitname: Optional[str] = None,
    factname: Optional[str] = None,
) -> str:
    '''
    Formats a given number to its representation in units with factors.

    Parameters
    ----------
    org : float
        The number as converted to the desired factor.
    flt : Optional[float], default=None
        The original number prior to factor conversion.
    unt : Optional[unit], default=None
        An instance of a unit class representing the base unit.
    fct : Optional[fact], default=None
        The factor class for the desired unit representation.
    shownumb : Optional[bool], default=True
        Whether to show the original number (`flt`) or the scaled number (`org`).
    abrvunit : Optional[bool], default=False
        Whether to show the abbreviation or the full name of the unit.
    factrepr : Optional[str], default='abrv'
        Representation of factor class to use, options include 'name', 'abrv', and 'symb'.
    ndig : Optional[int], default=3
        Number of significant digits to represent the number.
    unitname : Optional[str], default=None
        Override for the unit's name.
    factname : Optional[str], default=None
        Override for the factor's name or representation.

    Returns
    -------
    str
        Formatted string representation of the number in units with factors.

    Examples
    --------
    >>> fmtunit(1000.0, flt=1, unt=second, fct=kilo, factrepr='name')
    '1 Kilosecond'
    >>> fmtunit(1000.0, flt=1, unt=second, fct=kilo, factrepr='abrv')
    '1 Ksecond'
    >>> fmtunit(1000.0, flt=1, unt=second, fct=kilo, factrepr='symb')
    '1 KS'
    '''
    
    fnum = float(org)
    if flt is None: return str(fnum)
    if ndig is None: ndig = getattr(unt, 'ndig', 3)

    if factrepr is None: factrepr = getattr(fct, 'factrepr', 'abrv')
    if shownumb is None: shownumb = getattr(unt, 'shownumb', True)
    if unitname is None: unitname = (getattr(unt, 'name', None) or '')
    if abrvunit is None: abrvunit = getattr(unt, 'abrv', False)
    if factname is None: factname = (getattr(fct, factrepr, '') or '')
    
    fname = factname or ''
    if fname.casefold() == 'one': fname = ''

    if shownumb == False: fname = ''
    num = flt if shownumb else fnum

    nstr = fmtnumb(num, ndig)
    uname = pluralize(unitname, num)
    uname = uname[0].title() if abrvunit else uname
    match factrepr:
        case 'name': return f'{nstr} {fname.title()}{uname}'
        case 'abrv': return f'{nstr} {fname.title()}{uname}'
        case 'symb': return f'{nstr} {fname}{uname[0].title()}'
        case _: return str(fnum)


class umeta(type):
    def __new__(cls, name, bases, cls_dict):
        # Generate the attributes for the SI units
        cls_dict['units'] = fact.facts # e.g. (one, decka, hecto, ..., quetta, deci, ..., quecto)
        ucls = super().__new__(cls, name, bases, cls_dict)
        
        for fcls in fact.facts:            
            cfunc = ucls.getcfunc(fcls=fcls)
            for attr in ('abrv', 'name', 'symb'):
                if (aval := getattr(fcls, attr, None)) is not None: 
                    setattr(ucls, aval, property(cfunc))        
        return ucls
    
class unit(float, metaclass=umeta):
    # ------------------------------------------------------------------------------------
    # class variables & methods
    # ------------------------------------------------------------------------------------
    name: ClassVar[str]
    debug: ClassVar[bool] = False
    @classmethod
    def getcfunc(cls: type, fcls: fact):
        '''returns a class method that converts the unit to the given factor class'''
        def cfunc(self, inplace: bool = False):
            return self.to(fcls, inplace)
        return cfunc

    # ------------------------------------------------------------------------------------
    # instant variables & methods
    # ------------------------------------------------------------------------------------
    core: Optional[float]        # NOTE: this is the number that is actually stored in the float    
    base: Optional[int] = BASE   # base to use for the conversion
    fcls: Optional[fact] = one   # factor class to use, defaults to `one` (e.g. e0=1) so that `unit` works as float as expected by default
    numb: Optional[float] = None # NOTE: this is the number used for the conversion
    ndig: Optional[int] = 3      # number of digits to show

    shownumb: Optional[bool] = True
    abrvunit: Optional[bool] = True
    factrepr: Optional[FactRepr] = 'symb'

    def __new__(cls, core: float, *args, **kwargs):
        obj = float.__new__(cls, core)
        return obj
    
    @classmethod
    def clsdef(cls, attr: str, default: Any = None, **kwargs):
        '''Get the `attr` from `kwargs`, the class, or the `default`, whichever is found first'''
        val = kwargs.get(attr, getattr(cls, attr, default))
        return default if val is None else val

    def __init__(self, core: float, **kwargs):
        attrs = ('numb', 'base', 'fcls', 'ndig', 'shownumb', 'abrvunit', 'factrepr')
        adefs = tuple(self.clsdef(attr, (core if attr == 'numb' else None), **kwargs) for attr in attrs)
        for i, attr in enumerate(attrs):
            setattr(self, attr, adefs[i])

    @property
    def factor(self) -> fact:
        '''actually applies the factor to the base'''
        return self.fcls(self.base)

    def __str__(self) -> str:
        ustr = fmtunit(
            org = float(self.numb), flt = float(self), unt = self, fct = self.fcls,
            factrepr = self.factrepr, shownumb = self.shownumb,
            abrvunit = self.abrvunit, unitname = self.name, ndig = self.ndig
        )
        return ustr
    
    def __repr__(self) -> str:
        if type(self).debug:
            return f'{float(self)} (o={self.numb}, b={self.base}, f={self.fcls.__name__})'        
        return str(self)

    def to(self, key, inplace: bool = False):
        fcls = fact.get(key)
        if not fcls: raise FactorError(key)
        fcls = fcls(self.base)
        curr = self.numb / fcls
        if inplace:
            return type(self)(core=curr, numb=curr, base=self.base, fcls=one(self.base))        
        return type(self)(core=curr, numb=self.numb, base=self.base, fcls=fcls)
    

Phew, done. It feels mostly like string formatting >.< In fact, let's highlight the formatting a bit just to get my times worth.

import pandas as pd

results = list()
factors = (tera, decka, deci, centi, milli, pico)
for fcls in factors:
    for flt in (1, 20, 0.03):
        for factrepr in {'abrv', 'name', 'symb'}:
            for shownumb in (True, False):
                for abrvunit in (True, False):
                    res = fmtunit(
                        org = flt, flt=flt / float(fcls()), unt = unit, fct = fcls,
                        factrepr=factrepr, shownumb=shownumb, abrvunit=abrvunit,
                        unitname='second', factname=None, ndig=3
                    )
                    
                    results.append(dict(
                        fname=fcls.name, flt=flt, res=res, org = flt / float(fcls()),
                        shownumb=shownumb, abrvunit=abrvunit, factrepr=factrepr
                    ))

df = pd.DataFrame(results).sort_values(by=['fname', 'res'])
df.head()
fname flt res org shownumb abrvunit factrepr
134 hundredth 0.03 0.03 S 3 False True name
138 hundredth 0.03 0.03 S 3 False True abrv
142 hundredth 0.03 0.03 S 3 False True symb
143 hundredth 0.03 0.03 S 3 False False symb
135 hundredth 0.03 0.03 seconds 3 False False name

If we want to actually inspect all of these:

df.res
res
134 0.03 S
138 0.03 S
142 0.03 S
143 0.03 S
135 0.03 seconds
139 0.03 seconds
110 1.0 S
114 1.0 S
118 1.0 S
119 1.0 S
111 1.0 second
115 1.0 second
112 100.0 CentiS
113 100.0 Centiseconds
108 100.0 HundredthS
109 100.0 Hundredthseconds
116 100.0 cS
117 100.0 cS
122 20.0 S
126 20.0 S
130 20.0 S
131 20.0 S
123 20.0 seconds
127 20.0 seconds
124 2000.0 CentiS
125 2000.0 Centiseconds
120 2000.0 HundredthS
121 2000.0 Hundredthseconds
128 2000.0 cS
129 2000.0 cS
136 3.0 CentiS
137 3.0 Centiseconds
132 3.0 HundredthS
133 3.0 Hundredthseconds
140 3.0 cS
141 3.0 cS
68 0.003 DS
69 0.003 DS
64 0.003 DeckaS
65 0.003 Deckaseconds
60 0.003 TenS
61 0.003 Tenseconds
62 0.03 S
66 0.03 S
70 0.03 S
71 0.03 S
63 0.03 seconds
67 0.03 seconds
44 0.1 DS
45 0.1 DS
40 0.1 DeckaS
41 0.1 Deckaseconds
36 0.1 TenS
37 0.1 Tenseconds
38 1.0 S
42 1.0 S
46 1.0 S
47 1.0 S
39 1.0 second
43 1.0 second
56 2.0 DS
57 2.0 DS
52 2.0 DeckaS
53 2.0 Deckaseconds
48 2.0 TenS
49 2.0 Tenseconds
50 20.0 S
54 20.0 S
58 20.0 S
59 20.0 S
51 20.0 seconds
55 20.0 seconds
98 0.03 S
102 0.03 S
106 0.03 S
107 0.03 S
99 0.03 seconds
103 0.03 seconds
100 0.3 DeciS
101 0.3 Deciseconds
96 0.3 TenthS
97 0.3 Tenthseconds
104 0.3 dS
105 0.3 dS
74 1.0 S
78 1.0 S
82 1.0 S
83 1.0 S
75 1.0 second
79 1.0 second
76 10.0 DeciS
77 10.0 Deciseconds
72 10.0 TenthS
73 10.0 Tenthseconds
80 10.0 dS
81 10.0 dS
86 20.0 S
90 20.0 S
94 20.0 S
95 20.0 S
87 20.0 seconds
91 20.0 seconds
88 200.0 DeciS
89 200.0 Deciseconds
84 200.0 TenthS
85 200.0 Tenthseconds
92 200.0 dS
93 200.0 dS
170 0.03 S
174 0.03 S
178 0.03 S
179 0.03 S
171 0.03 seconds
175 0.03 seconds
146 1.0 S
150 1.0 S
154 1.0 S
155 1.0 S
147 1.0 second
151 1.0 second
148 1000.0 MilliS
149 1000.0 Milliseconds
144 1000.0 ThousandthS
145 1000.0 Thousandthseconds
152 1000.0 mS
153 1000.0 mS
158 20.0 S
162 20.0 S
166 20.0 S
167 20.0 S
159 20.0 seconds
163 20.0 seconds
160 20000.0 MilliS
161 20000.0 Milliseconds
156 20000.0 ThousandthS
157 20000.0 Thousandthseconds
164 20000.0 mS
165 20000.0 mS
172 30.0 MilliS
173 30.0 Milliseconds
168 30.0 ThousandthS
169 30.0 Thousandthseconds
176 30.0 mS
177 30.0 mS
8 0.0 TS
9 0.0 TS
20 0.0 TS
21 0.0 TS
32 0.0 TS
33 0.0 TS
4 0.0 TeraS
16 0.0 TeraS
28 0.0 TeraS
5 0.0 Teraseconds
17 0.0 Teraseconds
29 0.0 Teraseconds
0 0.0 TrillionS
12 0.0 TrillionS
24 0.0 TrillionS
1 0.0 Trillionseconds
13 0.0 Trillionseconds
25 0.0 Trillionseconds
26 0.03 S
30 0.03 S
34 0.03 S
35 0.03 S
27 0.03 seconds
31 0.03 seconds
2 1.0 S
6 1.0 S
10 1.0 S
11 1.0 S
3 1.0 second
7 1.0 second
14 20.0 S
18 20.0 S
22 20.0 S
23 20.0 S
15 20.0 seconds
19 20.0 seconds
206 0.03 S
210 0.03 S
214 0.03 S
215 0.03 S
207 0.03 seconds
211 0.03 seconds
182 1.0 S
186 1.0 S
190 1.0 S
191 1.0 S
183 1.0 second
187 1.0 second
184 1000000000000.0 PicoS
185 1000000000000.0 Picoseconds
180 1000000000000.0 TrillionthS
181 1000000000000.0 Trillionthseconds
188 1000000000000.0 pS
189 1000000000000.0 pS
194 20.0 S
198 20.0 S
202 20.0 S
203 20.0 S
195 20.0 seconds
199 20.0 seconds
196 20000000000000.0 PicoS
197 20000000000000.0 Picoseconds
192 20000000000000.0 TrillionthS
193 20000000000000.0 Trillionthseconds
200 20000000000000.0 pS
201 20000000000000.0 pS
208 30000000000.0 PicoS
209 30000000000.0 Picoseconds
204 30000000000.0 TrillionthS
205 30000000000.0 Trillionthseconds
212 30000000000.0 pS
213 30000000000.0 pS

Ok ok, yeah it is not perfect, but that isn't bad. I mean unit is even chainable:

class second(unit): 
    name = 'second'

second(2).to(3).to(-6)
2000000.0 uS

Conclusion

Does it work? yes. Does it do everything as I want it to? Not quite:

  • unit needs to overload multiplication and such to keep the unit class
  • recall the following example:
>>> kb = kilo(base=2)
>>> kb.abrv, kb.base, kb.expo, kb.sign, kb.ekey, float(kb)

('kilo', 2, 3, 1, 3, 8.0)

Yeah that is 2 ** 3 as expected as kilo is by default base 10 so 10 ** 3 but I wanted to be able to do the following:

class byte(unit): 
    name = 'byte'
    base = 2

b1k = byte(1024)
b1k, b1k.kilo, b1k.milli

(1024.0 B, 128.0 KB, 8192.0 mB)

Well I can do that, it isn't even that the fact class doesn't expose base it is that all of the exponents are hard set for each class (recall):

class decka(pfact): name, abrv, symb, expo = 'ten', 'decka', 'D', 1
class hecto(pfact): name, abrv, symb, expo = 'hundred', 'hecto', 'H', 2
class kilo(pfact): name, abrv, symb, expo = 'thousand', 'kilo', 'K', 3

for a kilobyte we need base = 2 and expo = 10 as well as adjusting all the other factors. I would appreciate suggestions on how to further improve these classes.

Any feedback is greatly appreciated. I look forward to learning from all of you.

\$\endgroup\$
4
  • 1
    \$\begingroup\$ You may be interested in Pint. \$\endgroup\$
    – Peilonrayz
    Oct 8, 2023 at 15:01
  • \$\begingroup\$ @Peilonrayz yes thank you for the reference. I was aware prior to starting this. I wanted to (1) just practice something not too difficult myself and (2) tackle this first "blind" so when I go in and read their source code I might appreciate their design decisions better :) \$\endgroup\$
    – SumNeuron
    Oct 8, 2023 at 15:03
  • \$\begingroup\$ FYI, the approach taken by the Public API of unyt is to construct a numeric object with e.g. mass = 2 * kg. And then things like print(mass + 3 * g) will display nicely. It also raises an appropriate exception if we attempt such foolishness as mass + 4 * ft. Prohibiting mixture of KB and KiB (decimal and binary) might be interesting. \$\endgroup\$
    – J_H
    Oct 8, 2023 at 15:05
  • \$\begingroup\$ re. a kilobyte is 1,024 bytes - it kind of isn't \$\endgroup\$
    – Reinderien
    Oct 8, 2023 at 15:08

1 Answer 1

1
\$\begingroup\$

It's good that you're practicing, and since you're practicing, you should be practicing PEP8-standard formatting (most easily assisted with a linter). This would make the class second Second, would increase spacing between a lot of your functions, etc.

In this output:

(1.0 S, 0.001 KS, 1000.0 mS, 0.001 KS, 0.001)

K has the incorrect case and should be k.

Re.

a kilobyte is 1,024 bytes (2^10)

this is not a good decision, and to avoid ambiguity you should be using the IEC kibibyte-like terminology instead.

It's not a carot, it's a caret.

iseq, getbval and getmval, since they deal in arbitrary attributes, will present a barrier to static analysis and are a code smell. The code should be refactored so that it doesn't need to rely on arbitrary attributes.

Abbreviations like dcsrct are not helpful; just write out deconstruct; and format_unit instead of fmtunit.

It's not useful to have a dunder prefix on __flbk, especially since that's a function parameter and not a class member. I can't even guess what this actually means - flyback?

When you're adding typehints for collections, don't leave them as unspecified (e.g. list() -> list); write out what they contain with []. If you can't (i.e. if it's Any), that's also a code smell.

decka is misspelled and should be deca.

Why does ThousandthS end in a capital? It probably shouldn't. Trillionseconds is also a strange choice for formatting and should read trillion seconds.

\$\endgroup\$
4
  • \$\begingroup\$ I appreciate your feedback, comments, critiques, and suggestions. Well that is embarrassing >.< 🥕 is gone (same for decka --> deca). Yes pep8, and full snake_case variable names would be better. Could you give an example of what you mean by "The code should be refactored so that it doesn't need to rely on arbitrary attributes." As for some of the formatting, the fmtunit function there is abbreviating the unit (second to S) \$\endgroup\$
    – SumNeuron
    Oct 8, 2023 at 16:29
  • 2
    \$\begingroup\$ The abbreviation for second is s, not S \$\endgroup\$
    – Reinderien
    Oct 8, 2023 at 16:34
  • \$\begingroup\$ Yes, I was making this with things like gigabyte in mind (some parses represent it as GB and since formatting the strings wasn't the main objective just capitalized it). Sorry \$\endgroup\$
    – SumNeuron
    Oct 8, 2023 at 16:39
  • \$\begingroup\$ No need to apologise, but yes, unit capitalization matters. Byte is correct as B. \$\endgroup\$
    – Reinderien
    Oct 8, 2023 at 16:40

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.