-
Notifications
You must be signed in to change notification settings - Fork 0
Pythonkunde
Al programmerende leer je meer van de mogelijkheden van een taal, of anders gezegd gaandeweg het programmeren zie je in dat je van een facet van de taal geen bal snapt. Dan zoek je dat uit. Deze pagina is een verzameling van die zoektocht, om te voorkomen dat hetgeen geleerd is weer zo makkelijk vervliegt.
https://stackoverflow.com/questions/28130722/python-bytes-concatenation
https://stackoverflow.com/questions/8263899/read-into-a-bytearray-at-an-offset
In Python zitten standaard al een hoop wiskundige functies. Andere bibliotheken en utilities die gebruikt worden bij manipulatie van veel getallen (arrays) zijn Numpy en SciPy. Lmfit is een interessante uitbreiding op de laatste, met name voor curvefitting/schatten van parameters. Lmfit heeft hele mooie en uitgebreide documentatie.
Om het automatisch meten van een frequentieresponsie te verbeteren wordt er in de code gebruik gemaakt van lmfit.Model() met de bijbehorende lmfit.Model().fit(). Een frequentieresponsie is niks anders dan een AC sweep met sinussen. Je kies een start frequentie en je gaat in een aantal stappen naar een zelfgekozen stopfrequentie. Bij elke frequentie meet je met de oscilloscoop zowel het ingangssignaal als het uitgangssignaal. Door de beide sinusvormige waveforms fit een wiskundige sinus functie, waarbij het gaat om de parameters van beide signalen. Dat is toegevoegd omdat een simplistische meting door bij elke frequentie het maximum van de waveform te bepalen een lelijke plot van de overdracht opleverde. Het plotten van de parameters van de beide gefitte sinussen leverde een veel mooiere overdrachtsplot op.
Tijdens de ontwikkeling van Labcontrol ben ik de volgende issues tegen gekomen:
- Fitten van een sinus gaat exponentieel meer tijd kosten ingeval van waveforms bestaande uit veel samples.
- Op de windows 11 werklaptop van Windesheim lijkt Python geen processor rescources te krijgen. Dit lijkt samen te vallen met bovenstaand punt.
- Er is een verschil tussen debugmode uitvoering en normal uitvoering (op een windows 11 Windesheim werklaptop): de code werkt wel (in acht nemen bovenstaande twee punten) in debug mode (zonder breakpoints), maar niet in normale uitvoeringsmode.
Ad 1. Bovenstaande uitspraak is gevolg van test op 21-4-2026 met een Siglent SDS1202X-E, waarbij er waveforms van 700k werden gemaakt. In debug mode duurde het dan heel erg lang voordat één fit klaar zou zijn. Ik heb daar niet opgewacht, ik heb uitvoering afgebroken en de bufferdiepte teruggeschroefd naar 7k samples. Nu werd de fit in redelijke tijd uitgevoerd.
Ad 2 zie:
https://stackoverflow.com/questions/33432204/launch-program-with-python-on-a-specific-core
https://learn.microsoft.com/en-us/windows/win32/procthread/processor-groups
Ad 3. Wanneer de sinusfitting wel werkt in debub, krijg je bij normale uitvoering de volgende melding:
File "C:\Users\p78511225\.pyenv\pyenv-win\versions\3.11.9\Lib\site-packages\lmfit\model.py", line 884, in _residual
diff = data - model
~~~~~^~~~~~~
ValueError: operands could not be broadcast together with shapes (7000,) (350,)
Ergens wordt een array een factor 20 kleiner gemaakt. Hoe dan? De melding "could not be broadcast" is een waarschijnlijk een fout vanuit Numpy. Met 'broadcasting' worden de regels bedoeld waarnmee Numpy probeert rekenkundige bewerkingen met matrices uit te voeren. Bij het uitvoeren van rekenkundige bewerkingen (+,-,*,/) in het geval dat de operanden matrices zijn, kent voorwaarden en regels, die verschillen per bewerking. Numpy heeft daar een aantal regels voor opgesteld (4 geloof ik), die van stal worden gehaald als de initële dimensies van de matrices niet passen bij de bewerking. In (het deel van) de foutmelding hierboven, staat een substracties van twee matrices. Het verschil tussen twee matrices is een elementsgewijze substracties. Dat kan hier niet: Numpy meldt dat de ene matrix 7000 groot is en de andere 350. Het eerste getal, 7000, kan kloppen, want dat is de ingestelde aantal samples per kanaal. Het tweede getal, 350, is mij duister.
- In deze link staat een mogelijk oorzaak. Het kan zijn dat een array ergens wordt omgezet in een list of ik heb ergens (foutief) een list ingepropt. Daar gaat dan een (Numpy) matrix vermenigvuldig niet goed op. Het schijnt dat een matrix berekening met een list datatype verkeerd kan gaan, omdat de (automatische) conversie van list naar np.array binnen de Numpy functie niet goed gaat. Daarom is het advies bij Numpy matrixberekeningen alle matrix operanden vooraf naar np.array te converteren.
- Het vreemde is dat de fitting bij debug wel goed werkt en bij normale uitvoering bovenstaande error geeft. Dat sluit bovenstaande misschien niet uit, maar geeft eerder aan dat bovenstaande niet dé oorzaak is en/of het enige probleem van mijn code. Nu ken ik de verschillen niet tussen normale en debug executie van Python scripts op Windwos 11, maar ik doe twee 'educated guesses': 1. Uitvoering van code gaat in debug mode langzamer en dan werkt het wel. Iets met timing? 2. In debug mode houdt de runtime allerlei registers en statussen bij. Mijn vermoeden is dan dat Read/Write data secties strak afgescherm (d.m.v een semafoor of mutex) worden. Als dat correct is, kan bovenstaande fout ontstaand omdat de twee fitting procedures, die in de code direcht na elkaar worden opgestart, elkaar beïnvloeden. Zou het kunnen zijn dat (een deel van) lmfit of de code daaronder liggend, niet reentrant is? Daar heb ik een kort onderzoekje naar gedaan, waarbij het lijkt dat de lmfit code wel concurrency ondersteunt en dus reentrant zou moeten zijn. Opties:
a. Uitsluiten van invloed van VSC, door uitvoering van de code op een linux machine of binnen WinPython (jupyter?
b. De code nogeens een keer as-is op werklaptop windesheim uitvoeren, maar dan met de Tektronix oscilloscoop. Deze construeert waveforms van 2500 samples en dan is de code een stuk sneller. De vraag is of dan de code wel werkt in normale executie mode. Opmerking: op 22/4/26 heb ik optie b uitgevoerd: as-is normaal laten runnen (dus NIET debug) met een Tektronix TDS2002C. Dat werkt goed, zie onderstaande plaatjes. Tenzij er andere gekke dingen onderwater gebeuren, denk ik dat een timeout afloopt, of dat een andere thread de inhoud van de databuffers die gebruikt worden bij het fitten, verminkt. De vraag is hoe deze hypothese kan onderbouwen/bewijzen.
c. De code herzien door het elke fit operatie in een eigen thread of zelf op een processor te duwen, met daarbij een eigen data-sectie die is afgescherm met semaforen of mutexen. Zie bovenstaande linkjes over het toekennen van een processor aan een Python taak en voor multiprocessing in Python, zie deze link
In aansluiting op eind van de sectie over lmfit, deze sectie over Pythons (ingebouwde) ondersteuning voor parallellisme. Python biedt een aantal mogelijkheden aan om blokkerende I/O calls of rekenintensieve taken die veel tijd kosten, parallel uit te voeren.
- multithreadingVSmulitprocess
- MultiprocessingOfClassMethod
- Concurrency-Python.org
- Multiprocessor-Python.org
- Concurrency-Python.org
- Run Python faster with c
- LaunchPythonProgrammOnASpecCore
- SpeedUpPythonWithConcurrency
- IntroPythonThreading
- PythonThreadsIntro
- PythonFreeThreadingJit
- ShowExecutorsInProcessPoolExecutor
- MultiProcAMemberFunc
- MultiProcMemFuncClassInArray
- MultiProcWithQueue
- Uitgebreide uitleg over Pool.map() functie
- Uitleg Pickel bij Multiprocessing
- lmfit icm multiprocessing
- parallellisme met Python ook met veel data
Multiprocessing lijkt goed te zijn uitgekauwd in Python en het lijkt ook goed te worden ondersteund door Windows 11. Er zijn veel manieren om hetzelfde voor elkaar te krijgen, waarbij het mij duidelijk is dat je goed moet weten wat je doet, anders a) retourneert de code niets of b) wordt de uitvoering van de code niet verdeeld over de fysieke processor. Laatste link hierboven, inhoud dateert van 8 jaar geleden, toont volgend voorbeeld met curvefit:
# Creating curve fit function called
def curvefunction(model, t, h, P0, lbounds, ubounds):
try:
opt, cov = curve_fit(model, t, h, p0=P0, bounds=(lbounds, ubounds))
except RuntimeError as e:
print(e)
opt = np.full(5, np.inf)
return opt
# Monte Carlo method to find best fit
data = []
for j in range(n_runs):
# Create list of tuples for starmap
i1, i2, i3, i4, i5 = np.random.choice(n_samaples, 5)
initial_guess = (amplitude[i1], period[i3], phase[i2], Velocity[i4], Acceleration[i5])
CurvefitValues = (sin_vel_model, t_diff, v, initial_guess, lower_limits, upper_limits)
data.append(CurvefitValues)
# Use Pool and curvefit to get all the parameters
with Pool(2*cpu_count()-1) as pool:
out = pool.starmap(curvefunction, data)
pool.close()
pool.join()Helaas niet een volledig voorbeeld maar ik kan er wel iets uithalen. Op basis van de andere links durf ik aan te nemen dat de volledige versie van bovenstaande code wel echt gewerkt heeft/zou kunnen werken. De reden is dat de functies en de data globale scope hebben in het voorbeeld. Dat is iets wat ik niet ga willen binnen Labcontrol, waar de fitting gedaan wordt binnen lidfuncties van klassen. Dat lijkt ook te moeten kunnen, zie onderstaand voorbeeld van stack overflow:
from multiprocessing import Pool
class Wrap:
def inside(self, a):
print(a)
def main():
pool = Pool()
pool.map(Wrap().inside, 'Ok' * 10)
if __name__ == '__main__':
main()Met bovenstaande wat getest. De manier van (om)denken zodat het (op mijn manier) past in labcontrol lukt niet zo best. Dat komt waarschijnlijk vooral omdat ik zelf graag werk met queues in geval met freertos of QNX. Daarom spreek de richting van de inhoud uit deze link mij veel meer aan:
def mp_factorizer(nums, nprocs):
def worker(nums, out_q):
""" The worker function, invoked in a process. 'nums' is a
list of numbers to factor. The results are placed in
a dictionary that's pushed to a queue.
"""
outdict = {}
for n in nums:
outdict[n] = factorize_naive(n)
out_q.put(outdict)
# Each process will get 'chunksize' nums and a queue to put his out
# dict into
out_q = Queue()
chunksize = int(math.ceil(len(nums) / float(nprocs)))
procs = []
for i in range(nprocs):
p = multiprocessing.Process(
target=worker,
args=(nums[chunksize * i:chunksize * (i + 1)],
out_q))
procs.append(p)
p.start()
# Collect all results into a single result dict. We know how many dicts
# with results to expect.
resultdict = {}
for i in range(nprocs):
resultdict.update(out_q.get())
# Wait for all worker processes to finish
for p in procs:
p.join()
return resultdictDe volledige code van de snippets die in de bron blog staat, kun je hier vinden.
Net elk apparaat reageert even handig op foutieve SCPI commando's. Robuuste code zou moeten controleren of een parameter wel mag en zou ook een input parameters moeten vertalen naar de juiste SCPI notatie, indien mogelijk. De oplossing tot nu is dat elke methode van elk afzonderlijke instrument driver, zowel de input parameters controleert op correcte type en range en vervolgens zorg draagt voor de vertaling in correcte SCPI. Dat heeft nadelen:
- Lange bestanden met enorme berg aan Python code.
- Veel dezelfde soort code per functie, zowel qua structuur als functionaliteit.
- Veel overlappende gelijksoortige code over de verschillende instrumentdrivers heen, waarbij uiteindelijk alleen het SCPI commando voor het instrument verschilt, indien het instrument die functionaliteit kan bieden.
Dat geeft de indruk dat de huidige codebase waarschijnlijk onnodig lomp groot is en dat de ontwikkeling ervan onnodig foutgevoelig is. Maar het geeft ook redenen dat het met gelaagdheid van de code niet goed zit: als feitelijk alleen de SCPI commandostring per vergelijkbare functie van verschillende typen oscilloscopen verschilt, een detail dus, zou je dat moeten kunnen scheiden van de "range checking" die in bijna elke functie zit die labcontrol rijk is.
Een mogelijke oplossing is het gebruik van het dictionary type en al dan niet in combinatie met het list type. Dit is een denkwijze die met vallen en opstaan in de SDS2000 series oscilliscopen van Sigent wordt geïmplementeerd. Die denkwijze is gebaseerd op:
- Scheiden correcte SCPI schrijfwijze van de implementatie. Daarvoor wordt een dictionary met meerdere niveaus gebruikt, waarvan de 'keys' de logische besturing volgt zoals de fabrikant in het apparaat heeft ontworpen en waarbij het laatste (value) element altijd het correcte SCPI commando is dat naar het instrument kan worden verstuurd. Hierbij is ervoor gekozen dat de eventuele parameters alsnog kunnen worden ingevuld. Daarom is het uiteindelijke SCPI commando in de dictionary niet d.m.v. een 'str' type, maar m.b.v een zgn. lambda functie gecodeerd.
- Eventuele parameters die kunnen worden ingevuld in de SCPI-command-lambda functie, zijn eveneens in een dictionary gezet met dezelfde structuur als de SCPI dict, hierboven beschreven. Maar in dit geval is het laatste (value) element niet een lambda functie, maar een list, dat de geldige waarden en SCPI schrijfwijze bevalt.
- Een tweetal standaard klassen SCPIcommand en SCPIParam waarmee er gezocht kan worden in de dictionaries waarmee de correcte lambda functie en bijbehorende parameter lijsten gevonden kunnen worden alsmede de constructie en teruggeven van de gewenste SCPI commando's, het uiteindelijke doel van deze operatie.
Om een lambda of parameterlijst op te kunnen zoeken is gekozen voor een zogenaamde 'dict-index'. Dat is een lijst (array), waarvan de elementen de sleutels (keys) in de SCPI en PARAM dictionaries zijn, zodat het gewenste 'value item' gevonden kan worden: een SCPI lambda functie of een parameterlijst.
https://builtin.com/software-engineering-perspectives/convert-list-to-dictionary-python
Labcontrol bevat code om uit meetdata de overdrachtsfunctie te schatten. Die status van die code is "nogal houtje touwtje". Maar zodra dit stabiel in labcontrol verwerkt is, zou je meer het die gefitte overdracht kunnen, zoals het opnemen van meetapparatuur om "hardware & software in the loop" te kunnen doen (HIL/SIL). Via Martin heb ik wat tips gekregen over bronnen die gaan over de combi Python en Regeltechniek https://python-control.readthedocs.io/en/0.10.1/intro.html
Op zoek naar iets en dit gevonden: https://github.com/LabPy/lantz/tree/master https://lantz.readthedocs.io/en/0.3/tutorial/building.html
Soms kijk je bij problemen niet tegen een Python eigenschap aan, maar een Windows beheer dingetje.
- Voor het makkelijk beheren van verschillen Python versies naast elkaar, installeer pyenv voor Windows. Voor informatie hierover klik hier
- Als je pyenv de Python versies laat beheren, zorg dan dat alle referenties naar andere geinstalleerde varianten van Python uit de
Pathomgevingsvariabele zijn gehaald. Waarbij geldt: zowel uit jouw user path als uit je system path. Doe je dat niet dan zal Windows bij missende pakketjes verder zoeken in het path en als er dan een python versie die de functie wel heeft, pakt Windows die!
Zie ref.
- Alles is een object
- Elk object heeft een identiteit, een type en (een) waarde.
- De identiteit van een object verandert nooit: dus aanpassen van een object na creatie is onmogelijk!
- De is() operator vergelijkt de identiteit van twee objecten met elkaar.
- De id() functie geeft de identiteit van een object in een integer waarde weer, wat ultimo overeenkomt met het geheugenadres van het object.
- Het type van een object bepaalt welke operaties het object ondersteunt. De type() functie retourneert een object type, wat zelf ook een object is. Het type van object verandert nooit.
Voor de ontwikkeling van Labcontrol bleek gaandeweg dat ik te weinig Python kennis had: je denkt dat je de functionaliteit op de juiste wijze hebt gecodeerd, weigert de boel! Voorbeeld is het geklier met de @property decorator i.c.m. overerving. Het geheel werkte niet lekker, dus heb ik alle decorators weer uit de code gehaald. Vervolgens ben je weer een poosje aan het klooien met een implementatie van een andere feature. Oorzaak is telkens hetzelfde: te weinig kennis van Pythons binnenkant i.c.m. de hoop dat ik met bestaande (C++/C#/Java) kennis er wel uitkom. Niet dus en vaak is dat de levenscyclus van een object binnen Python en met name de start ervan. Hier wordt het proces van het aanmaken van een object uitgelegd. Standaard dingen zijn niet moeilijk, maar als je dat nu net niet wilt, staat hier uitgelegd hoe e.e.a in elkaar steekt. Er zijn twee methoden/functies belangrijk in het creatietraject: new en init.
Uit de link kun je het volgende halen:
- New() en init() hoef jezelf niet perse te implementeren. Python maakt voor jouw klasse een standaard new() en init() methode aan.
- Init() zorgt voor de initialisatie van de datamembers. Implementaties van init() komt eerder voor dan van new().
- New() wordt door Python eerst aangeroepen, hier wordt het object gemaakt. Daarom retourneert new() meestal het aangemaakte object en zoals hierboven te lezen is: als een object bestaat, kun de id ervan niet meer aanpassen.
- Na new() wordt init() aangeroepen, waarin het object startwaardes kan worden meegegeven. Omdat het object bestaat en niet veranderd kan worden, retourneert init() nooit iets. Probeer je dat wel (anders dan None), dan krijg je foutmelding.
- Bovenstaande betekent simpel weg dat een factory-patroon in/via/voor het eind van de new functie geregeld moet zijn.
- tijdens de uitvoering van new() verander dan je het type (=de waarde van cls) -> Zorg dan dat je voor het verlaten van new() de aangepast cls als parameter meegeeft aan de instructie super().new(). Doe je dat niet, dan wordt er ook geen object gecreeërd, althans niet de goede.
- Als new() het nieuwe object retourneert, hoef je nergens in het proces de init() van jouw eigen klasse aan te roepen. Wel kan het verstandig zijn om binnen de eigen init() functie eerst super().init() uit te laten voeren, zodat je er zeker van kan zijn dat alle members van het nieuwe object op juiste wijze geïnitialiseerd worden.
- Sinds Python 3.6 worden klassen op een andere wijze geïnitialiseerd. Dit staat beschreven in PEP487, zie link. Op deze manier kun je gemakkelijk een soort van plug-in systeem maken, iets wat labcontrol wel kan gebruiken.
In file1.py staat de code voor een BaseScope implementatie
class BaseScope(object):
scopeList = []
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
cls.scopeList.append(cls)
@classmethod
def getDevice(cls, id):
return None
def __new__(cls, id=0):
instance = None
for scope in cls.scopeList:
scopeObj = scope.getDevice(id)
if scopeObj is not None:
#return scopeObj
return super().__new__(scopeObj)
else:
instance = super().__new__(cls)
return instance
return instance
def __init__(self, id=0):
self.myid = id
self.channels = None
def method1(self):
print("method1 BaseScope")
def method2(self):
print("method2 BaseScope")En in file2.py staat code die overerft van BaseScope:
from file1 import BaseScope
class MyScope1(BaseScope):
@classmethod
def getDevice(cls, id):
if cls is MyScope1:
if id==1:
return cls
return None
def __init__(self, id):
super().__init__(id)
self.varretje1 = 5
self.channels = []
def method1(self):
print("method1 myscope")
#super().method1()Het geheel wordt aangeroepen in main.py:
from file1 import BaseScope
from file2 import MyScope1
scope = BaseScope()
scope2 = MyScope1(1)
scope2.method1()
scope2.method2()
scope.method1()
scope.method2()wat dan de volgende output oplevert:
method1 myscope
method2 BaseScope
method1 BaseScope
method2 BaseScope
Jupyter Notebook. In eigen woorden: een webbased opensource Word met ingebouwde opensource MATLAB. Anders geformuleerd: een beperkte online tekstverwerker (Markup) met krachtige gereedstaande symbolische calculator.
Vanaf eerste moment dat ik het tegenkwam, vond ik het een mooi en interessant platform. Absoluut geen eendagsvlieg, want bestaat al > 10 jaar. Zie hier om meer te weten van de herkomst van Jupyter. Hoewel ik wist dat het bestond en er erg goed uitzag, heb ik het lang links laten liggen vanwege onhandigheid met Python. Via Douwe en Leo vd P gezien wat het zou kunnen en vanwege de goede ervaringen van het documenteren in Markup op Github, er toch zelf mee aan de slag gegaan. Hoewel dat zeker niet het plan was, zie Jupyter nu als het raamwerk waarvan ik denk dat het de beste manier is om Labcontrol te draaien, in tegenstelling tot handmatige uitvoering of executie binnen een IDE, zoals Visual Studio (Code).
Standaard plotten in Jupyter geeft alleen de plot zelf. Geen mogelijkheid tot in- of uitzoomen. Dat kan je aanpassen, waarbij de opmerking is dat er verschillende manier op Internet rondgaan. Niet alles werkt bij een recente versie van Jupyter Notebook. De aanpak is:
- import matplotlib in een codeblok.
- Voeg de volgende instructie tot: '%matplotlib widget'
- Maak de plot via het plot commando in jouw code blok.
Zie ook volgende link uit 2018 (vandaar dat die ook niet helemaal up to date is): https://medium.com/@Med1um1/using-matplotlib-in-jupyter-notebooks-comparing-methods-and-some-tips-python-c38e85b40ba1
Jupyter's website vermeld duidelijk dat JupyterLab de toekomst is
'Automated Testing' is een techniek om software te testen (te valideren) op correcte functionaliteit, zie hier en dit artikel voor uitleg hierover. Deze tak van sport is enorm gegroeid dankzij 'Agile' en 'DevOps' methodieken, wat je ook weer terug kun zien in 'Continuous Delivery' (CD) & 'Continious Integration' (CI). Doel: het software(test)proces sneller maken, door het testen zoveel mogelijk te automatiseren. Zodra ontwikkelaars nieuwe functionaliteit in de software hebben geschreven en in de repository hebben ingecheckt, controleert CI de nieuwe code op fouten en gevaren en CD controleert of de software wel goed werkt, automatisch! Zie: bron.
Na een zeer kort onderzoek op Internet trek als voorzichtig conclusie: het eerste om eens uit te proberen is de ingebouwde module unittest. Handig, installeren is niet nodig. bron. Een ander, mogelijk groot voordeel van unittest is het bieden van 'mocking'. Een 'mock' of een 'mock-up' is hulpsoftware dat fysieke interfaces als het ware 'faket', zodat je kan testen, zonder de normaal benodigde hardware of netwerkconnecties. Daarnaast blijkt er een interessante, overkoepelende aanstuursoftware te zijn voor unittest: coverage.py. Zie link
#Niet mokken maar mocken De unittest module heeft interessante submodules: Mock. Een goed blog over het gebruik van Mock kun je hier lezen.
Pyvisa-sim, de simulator van Python uitvoering van VISA, werkt helaas niet echt lekker: je moet behoorlijk editen in de 'happy flow' van de code om pyvisa-sim werkend te krijgen. Dat past niet in het denken over gestructureerd software schrijven. Gelukkig blijkt er, in aansluiting over bovenstaande opmerkingen over mock-ups een pyvisa mock-up implementatie te bestaan:

Op de github pagina van deze mock staat een korte uitleg. Veel belovend: in plaats van een obscure yml file kun je heldere code scfhrijven, zoals hieronder. Dit lijkt beter dan pyvisa. De vraag is of dit wel goed werkt.

Zie link
Hieronder volgt een korte, niet specifiek gestructureerd en absoluut niet uitputtend, overzicht van aantal opties:
Zie hier voor een beschrijving.
link:
- LambdaTest. AI platform, maakt mogelijk om andere testplatformen te integreren.
- Unittest i.c.m. Nose2
Een hele goede uitleg betreffende Mocking in Python vind je hier Ook een aardige: een soort van cheatsheet, korte voorbeeldjes waar van alles en nog wat misgaat. zie hier
The Art of Mocking in Python: A Comprehensive Guide Moraneus
Moraneus ·
Follow 10 min read · May 21, 2024
When writing software, testing is crucial to ensure that your code behaves as expected. However, testing can sometimes be challenging, especially when your code depends on external systems like databases, APIs, or even hardware devices. This is where mocking comes into play. In this article, we’ll dive deep into the concept of mocking, explore how to use part of the unittest.mock module in Python, and provide useful examples to learn and start with. We'll also discuss alternatives to mocking and when you might want to use them. What is Mocking?
Mocking is a technique used in unit testing to simulate the behavior of real objects. By creating mock objects, you can test your code in isolation, without relying on external dependencies. This makes your tests faster, more reliable, and easier to maintain.
- Isolation: Test individual components without dependencies.
- Speed: Avoid slow operations like network calls or database queries.
- Control: Simulate specific scenarios and edge cases.
- Simplicity: Simplify complex test setups.
The unittest.mock module, included in Python's standard library, provides a powerful and flexible way to create mock objects. Let's start with a basic. example of mocking an API Call.
Suppose we have a simple function that fetches data from an API: """ import requests from typing import Dict
def get_data(url: str) -> Dict[str, str]:` response = requests.get(url) return response.json() """ To test this function without making an actual HTTP request, we can use mocking:
from unittest.mock import patch import unittest from typing import Dict
class TestGetData(unittest.TestCase): @patch('requests.get') def test_get_data(self, mock_get: patch) -> None: mock_response = mock_get.return_value # Create a mock response object mock_response.json.return_value = {'key': 'value'} # Define the return value of the mock response's json method
url = 'http://example.com/api'
result = get_data(url) # Call the function with the mocked response
self.assertEqual(result, {'key': 'value'}) # Assert the returned value is as expected
mock_get.assert_called_once_with(url) # Ensure the get method was called once with the correct URL
if name == 'main': unittest.main()
Explanation
The @patch decorator is a powerful tool for replacing parts of your system under test with mock objects.
Here's a breakdown of how it works:
Decorator Function: @patch('requests.get') tells the test runner to replace requests.get with a mock object.
Mock Object Injection: The mock object is passed as an argument to the test function (mock_get in this case).
Return Value: The mock object can be configured to return specific values or raise exceptions using its methods (e.g., mock_response.json.return_value).
By using the @patch decorator, you don't need to manually start and stop the patching, making your test code cleaner and easier to understand. Mocking File Operations
Consider a function that reads data from a file:
def read_file(filepath: str) -> str: with open(filepath, 'r') as file: return file.read()
This function takes a file path as an argument, opens the file in read mode, reads its contents, and returns them as a string. To test this function without creating an actual file on the filesystem, we can use the unittest.mock module to mock the built-in open function.
Here’s the test code:
from unittest.mock import patch import unittest
class TestReadFile(unittest.TestCase): @patch('builtins.open', new_callable=unittest.mock.mock_open, read_data='mock data') def test_read_file(self, mock_open: patch) -> None: result = read_file('dummy_path') # Call the function with the mocked open function
self.assertEqual(result, 'mock data') # Assert the returned value is as expected
mock_open.assert_called_once_with('dummy_path', 'r') # Ensure the open method was called once with the correct filepath and mode
if name == 'main': unittest.main()
Explanation
Decorator Function: The @patch('builtins.open', new_callable=unittest.mock.mock_open, read_data='mock data') decorator tells the test runner to replace the built-in open function with a mock object.
'builtins.open': This argument specifies the target to be replaced with a mock object. 'builtins.open' refers to the built-in open function in Python. If we were patching a function or method from a module, the target would be specified as 'module_name.function_name'.
new_callable=unittest.mock.mock_open: This argument specifies the type of mock object to use. unittest.mock.mock_open is a helper function that creates a mock to replace open. This mock object can be used to simulate file operations.
read_data='mock data': This argument specifies the data that should be returned when the mock file is read. By setting read_data='mock data', we configure the mock to return 'mock data' when its read method is called.
Mock Object Injection: The mock object is automatically passed as an argument to the test function (mock_open in this case).
Calling the Function: result = read_file('dummy_path') calls the read_file function with 'dummy_path' as the argument. Since the open function has been mocked, no actual file is opened. Instead, the mock object created by mock_open is used. The read method of this mock object returns 'mock data' as configured.
Asserting the Return Value: self.assertEqual(result, 'mock data') checks that the result of calling read_file('dummy_path') is 'mock data'. This verifies that the function behaves correctly when open is mocked.
Verifying the Mock Call: mock_open.assert_called_once_with('dummy_path', 'r') checks that the open function (replaced by the mock object) was called exactly once with the arguments 'dummy_path' and 'r'. This verifies that the function attempts to open the correct file in the correct mode.
By mocking the open function, we can simulate file operations without creating actual files on the filesystem, ensuring consistent and predictable test results. Mocking Time
Sometimes, you need to mock time-related functions in your tests to control and predict the passage of time, ensuring consistent and reliable test results.
Let’s start by defining the function we want to test:
import time
def get_timestamp() -> float: return time.time()
This function simply returns the current timestamp by calling time.time().
Here’s the test code for the get_timestamp function, which mocks the time.time() function:
from unittest.mock import patch import unittest
class TestGetTimestamp(unittest.TestCase): @patch('time.time', return_value=1625097600.0) # Mocking time.time to return a fixed timestamp def test_get_timestamp(self, mock_time) -> None: result = get_timestamp() # Call the function with the mocked time function
self.assertEqual(result, 1625097600.0) # Assert the returned value is as expected
mock_time.assert_called_once() # Ensure the time method was called once
if name == 'main': unittest.main()
Explanation
The @patch decorator is used to replace the time.time function with a mock object.
Here's a detailed breakdown:
Decorator Function: The @patch('time.time', return_value=1625097600.0) decorator tells the test runner to replace the time.time function with a mock object. The target 'time.time' specifies that we are patching the time function from the time module. By using return_value=1625097600.0, we configure the mock to return 1625097600.0 whenever time.time is called. This value corresponds to a specific fixed point in time (specifically, July 1, 2021, at 00:00:00 UTC).
Mock Object Injection: The mock object is automatically passed as an argument to the test function (mock_time in this case). This allows us to interact with and configure the mock within the test.
Configuring the Mock Return Value: The mock object is configured to return a specific value using return_value. In this example, mock_time.return_value is set to 1625097600.0, which represents a fixed timestamp. Whenever the get_timestamp function calls time.time(), it receives this fixed timestamp instead of the current time.
Calling the Function: result = get_timestamp() calls the get_timestamp function. Since the time.time function has been mocked, it returns 1625097600.0 instead of the current time.
Asserting the Return Value: self.assertEqual(result, 1625097600.0) checks that the result of calling get_timestamp() is `1625097600.
Verifying the Mock Call: mock_time.assert_called_once() ensures that the time.time function (replaced by the mock object) was called exactly once. This verifies that the function attempts to get the current time.
By mocking the time.time function, we can control the timestamp returned to the get_timestamp function, ensuring consistent and predictable test results. Mocking Classes and Methods
Sometimes, you need to mock entire classes or specific methods within a class. Consider a class Database that has methods to connect to a database and fetch data:
from typing import Dict
class Database: def connect(self) -> None: pass
def fetch_data(self) -> Dict[str, str]:
return {'data': 'real data'}
We also have a function process_data that uses this Database class:
def process_data() -> Dict[str, str]: db = Database() db.connect() return db.fetch_data()
To test the process_data function without actually connecting to a real database, we can use mocking.
Here’s the test code for the process_data function:
from unittest.mock import patch, MagicMock import unittest
class TestProcessData(unittest.TestCase): @patch('main.Database') def test_process_data(self, MockDatabase: MagicMock) -> None: mock_db = MockDatabase.return_value # Create an instance of the mock Database class mock_db.fetch_data.return_value = {'data': 'mock data'} # Define the return value of the fetch_data method
result = process_data() # Call the function with the mocked Database
self.assertEqual(result, {'data': 'mock data'}) # Assert the returned value is as expected
mock_db.connect.assert_called_once() # Ensure the connect method was called once
if name == 'main': unittest.main()
Explanation
Decorator Function: The @patch('__main__.Database') decorator tells the test runner to replace the Database class in the __main__ module with a mock object. This allows us to control and verify the behavior of the Database class during the test.
Mock Object Injection: The mock class is automatically passed as an argument to the test function (MockDatabase in this case). This allows us to interact with and configure the mock class within the test.
Creating a Mock Instance: mock_db = MockDatabase.return_value creates an instance of the mock Database class. This mock instance will be used to replace the actual Database class in the process_data function.
Configuring the Mock Return Value: mock_db.fetch_data.return_value = {'data': 'mock data'} sets the return value of the fetch_data method of the mock Database instance to {'data': 'mock data'}. This means that when fetch_data is called on the mock Database instance, it will return {'data': 'mock data'} instead of the real data.
Calling the Function: result = process_data() calls the process_data function. Since the Database class has been mocked, the function uses the mock Database instance instead of a real one.
Asserting the Return Value: self.assertEqual(result, {'data': 'mock data'}) checks that the result of calling process_data is {'data': 'mock data'}. This verifies that the function behaves correctly when the Database class is mocked.
Verifying the Mock Call: mock_db.connect.assert_called_once() checks that the connect method of the mock Database instance was called exactly once. This verifies that the function attempts to connect to the database.
Mocking Async Functions
With the rise of asynchronous programming, you might need to mock async functions. Here’s how you can do it using the unittest.mock module in Python:
Consider an asynchronous function that fetches data from an API:
import aiohttp import asyncio from typing import Dict
async def fetch_data(url: str) -> Dict[str, str]: async with aiohttp.ClientSession() as session: async with session.get(url) as response: return await response.json()
To test this function without making an actual HTTP request, we can use mocking:
from unittest.mock import AsyncMock, patch import unittest import asyncio
class TestFetchData(unittest.TestCase): @patch('aiohttp.ClientSession') def test_fetch_data(self, MockClientSession: AsyncMock) -> None: # Create an instance of the mock session mock_session = MockClientSession.return_value mock_session.aenter.return_value = mock_session
# Configure the mock response
mock_response = AsyncMock()
mock_response.json.return_value = {'key': 'value'}
mock_session.get.return_value.__aenter__.return_value = mock_response
# Run the async function within the event loop
result = asyncio.run(fetch_data('http://example.com/api'))
# Assertions
self.assertEqual(result, {'key': 'value'})
mock_session.get.assert_called_once_with('http://example.com/api')
if name == 'main': unittest.main()
Explanation
Patching the ClientSession Class
The @patch('aiohttp.ClientSession') decorator tells the test runner to replace the aiohttp.ClientSession class with a mock object. This allows us to control the behavior of the entire session. Here’s how it works:
Decorator Function: @patch('aiohttp.ClientSession') tells the test runner to replace the ClientSession class from the aiohttp module with a mock object.
Mock Object Injection: The mock class is automatically passed as an argument to the test function (MockClientSession in this case). This allows us to interact with and configure the mock within the test.
Creating and Configuring the Mock Session
mock_session = MockClientSession.return_value creates an instance of the mock session. This mock session will be used in place of a real ClientSession.
mock_session.__aenter__.return_value = mock_session configures the mock session to return itself when used as an asynchronous context manager. This is necessary because the ClientSession is used within a with statement in the fetch_data function.
Configuring the Mock Response
mock_response = AsyncMock() creates an AsyncMock instance for the response. This mock object will simulate the behavior of the HTTP response returned by the get method.
mock_response.json.return_value = {'key': 'value'} configures the json method of the mock response to return {'key': 'value'} when awaited.
mock_session.get.return_value.__aenter__.return_value = mock_response sets up the mock get method to return the mock response when awaited. This means that when session.get(url) is called, it returns the mock_response object.
Running the Asynchronous Function
result = asyncio.run(fetch_data('http://example.com/api')) runs the fetch_data function within the event loop and gets the result. The asyncio.run function is used to run the asynchronous function in a synchronous context.
Assertions
self.assertEqual(result, {'key': 'value'}) checks that the result of calling fetch_data is {'key': 'value'}. This verifies that the function correctly processes the mock response.
mock_session.get.assert_called_once_with('http://example.com/api') verifies that the get method was called with the correct URL. This ensures that the fetch_data function made the expected HTTP request.
Alternatives to Mocking
While mocking is a powerful tool, it’s not always the best choice. Here are some alternatives:
Fakes: Create simplified versions of real objects that mimic their behavior. For example, a fake database might store data in memory rather than on disk.
Stubs: Provide hard-coded responses for specific method calls. This is useful when you need consistent responses for your tests.
Spies: Track interactions with real objects without modifying their behavior. Spies can be used to verify that methods are called with the correct arguments.
Integration Tests: Test your code with real dependencies in a controlled environment. Integration tests are essential for ensuring that all components work together correctly.
Conclusion
Mocking is an essential technique for writing effective unit tests in Python. By using the unittest.mock module, you can create mock objects to isolate your tests, making them faster and more reliable. We've covered the basics of mocking, with some useful techniques, and alternatives to help you become proficient in writing tests for your Python code.
Remember, the key to successful testing is to choose the right tool for the job. Whether you use mocks, fakes, stubs, or spies, the goal is to ensure that your code works correctly in all scenarios. Happy testing! Your Support Means a Lot! 🙌
If you enjoyed this article and found it valuable, please consider giving it a clap to show your support. Feel free to explore my other articles, where I cover a wide range of topics related to Python programming and others. By following me, you’ll stay updated on my latest content and insights. I look forward to sharing more knowledge and connecting with you through future articles. Until then, keep coding, keep learning, and most importantly, enjoy the journey! Reference unittest.mock - mock object library Source code: Lib/unittest/mock.py unittest.mock is a library for testing in Python. It allows you to replace parts of…
docs.python.org Python Python Programming Mockup Testing Unittest
bron: zie link
Creating an executable from a Python script can significantly ease the distribution and execution process, making your application more accessible to users without Python installed. PyInstaller is a popular tool that simplifies this process by packaging Python scripts into standalone executables for Windows, Linux, and macOS. Here’s a comprehensive guide to using PyInstaller to create an executable from a Python script. Introduction to PyInstaller
You’ve developed a great Python script, and now you want to share it with the world. However, not everyone has Python installed, and figuring out how to run your script can be challenging for them. That’s where PyInstaller comes in handy. It’s a tool that takes your script, bundles all its dependencies, and creates a single executable file. This executable can be run with a simple double-click, without requiring Python to be installed. It’s an excellent way to make your Python creations accessible to a broader audience. Setting Up PyInstaller
Before using PyInstaller, ensure that Python is installed on your computer (version 3.5 or later). Once Python is set up, installing PyInstaller is straightforward. Open your command line tool (Terminal on macOS and Linux, Command Prompt on Windows) and run the following command:
pip install pyinstaller
From Script to Executable: Key Steps
By following these simplified steps, you can easily create an executable version of your Python script using PyInstaller.
-
Prepare Your Script: Before anything else, make sure your script runs smoothly in your Python environment. This means checking for and fixing any bugs or issues. It’s crucial to have your script in tip-top shape before you transform it into an executable, to ensure it behaves as expected when others use it.
-
Navigate to Your Script Directory: Open your command line or terminal. You’ll need to move into the directory where your Python script is saved. If you’re not familiar with command line navigation, you can use the cd command followed by the path to your script's folder to get there.
-
Run PyInstaller: Now, it’s time to invoke PyInstaller to do its magic. Type the command below, making sure to replace your_script.py with the actual name of your Python script:
pyinstaller --onefile your_script.py
The --onefile option is what tells PyInstaller to pack everything your script needs into one neat executable file. Without this option, PyInstaller would instead create a folder filled with your executable and any necessary support files, which can be a bit messier to distribute.
- Locate Your Executable: Once PyInstaller has done its job, it’s time to grab your newly created executable. You’ll find it waiting in the dist folder, which is located inside the same directory as your script. This file is now a standalone version of your Python script, ready to be shared and run on any compatible system, no Python installation required. Script-to-Executable Examples with PyInstaller
Let’s improve the guide by providing Python script examples corresponding to each shell command used for creating an executable with PyInstaller. This approach will help clarify how each command applies to specific script scenarios, ranging from simple scripts to those with external data, custom icons, and even GUI applications. Simple Script Example
Python Script (hello_pyinstaller.py):
print("Hello, Pyinstaller!")
Shell Command:
pyinstaller --onefile hello_pyinstaller.py
Including Data Files
Perhaps your script, app_with_data.py, relies on external files for its wisdom. PyInstaller allows you to include these essential companions.
When PyInstaller compiles your Python script into an executable, the file structure within the executable differs from your original development environment. Consequently, direct file paths (like ‘data/data_file.txt’ in your script) often fail to resolve correctly.
To address this, you need to modify your script to dynamically locate the data file at runtime, regardless of whether it’s being run as a script or as a compiled executable. PyInstaller sets a _MEIPASS attribute in the sys module for this purpose. This attribute contains the path to the temporary folder that PyInstaller uses to extract your bundled files when the executable runs.
Python Script (app_with_data.py):
import os import sys
def load_data(file_path): try: with open(file_path, 'r') as file: data = file.read() return data except FileNotFoundError: return "File not found."
def resource_path(relative_path): """ Get absolute path to resource, works for dev and for PyInstaller """ base_path = getattr(sys, '_MEIPASS', os.path.dirname(os.path.abspath(file))) return os.path.join(base_path, relative_path)
if name == "main": # Adjust the path to where you actually store 'data_file.txt' data_path = resource_path('data/data_file.txt') print(load_data(data_path))
This resource_path function checks if your script is running in a bundled context by looking for _MEIPASS. If it exists, it means your script is running inside a PyInstaller bundle, and it adjusts the path accordingly. Otherwise, it assumes the script is running in a development environment and uses the standard path.
This setup ensures that your executable can correctly locate and access data_file.txt, preventing the FileNotFoundError you've encountered.
Shell Command (Windows):
pyinstaller --onefile --add-data 'data\data_file.txt;data' app_with_data.py
In this command, 'data\data_file.txt;data' tells PyInstaller to take data_file.txt from the data directory and place it in the data directory of the bundled application.
Shell Command (Linux/Mac):
pyinstaller --onefile --add-data 'data/data_file.txt:data' app_with_data.py
The same happens here. This ensures your script and its data files remain inseparable, no matter where they go.
Notice the use of a colon (:) as the separator in the Linux command instead of a semicolon (;), which is used in Windows. Including Binary Files
Similar to adding data files, you can include binary dependencies not automatically detected by PyInstaller using the --add-binary option. This is particularly useful for including shared libraries and other binary resources. Do not forget to use the resource_path function as mentioned in the --add-data section.
Windows Example
Let’s say your application uses a specific dynamic link library (DLL) that PyInstaller does not automatically include. You can manually add this file using the --add-binary option. For example, if you have a library called example.dll located in a folder named libs next to your script, you would include it like this:
pyinstaller --onefile --add-binary 'libs\example.dll;.' your_script.py
In this command, 'libs\example.dll;.' tells PyInstaller to take example.dll from the libs directory and place it in the root directory of the bundled application (. signifies the root of your application's distribution folder).
Linux Example
For Linux, if you’re including a shared library (.so file), the process is similar. Assume you have a shared library called example.so in a libs directory. You would add it to your PyInstaller command like this:
pyinstaller --onefile --add-binary 'libs/example.so:.' your_script.py
Here, 'libs/example.so:.' instructs PyInstaller to include example.so from the libs directory into the root of the executable folder. Adorning with Icons: A Touch of Personalization
Your script, now an application, deserves to stand out. A custom icon, perhaps? Consider app_with_icon.py, an application that aspires to be recognized.
Python Script (app_with_icon.py):
print("An icon of my own...")
Shell Command:
pyinstaller --onefile --icon=your_icon.ico app_with_icon.py
Debugging: Peering Behind the Curtain
Imagine you’ve developed a Python script that performs flawlessly in your development environment, but when bundled into an executable, it behaves unexpectedly. Such scenarios are where PyInstaller’s --debug flag becomes invaluable, acting as your flashlight in the murky depths of executable behavior.
Consider a Python script, mystery_behavior.py, which reads from a configuration file and logs messages based on that configuration. However, when converted into an executable, it mysteriously fails to read the configuration properly.
import logging import configparser
config = configparser.ConfigParser() config.read('config.ini')
logging.basicConfig(level=logging.DEBUG) logging.debug("Starting the application...")
if config['DEFAULT'].getboolean('debug_mode'): logging.debug("Debug mode is enabled.") else: logging.debug("Debug mode is disabled.")
logging.debug("Performing tasks...")
In this script, everything seems straightforward, but the executable version doesn’t log messages as expected.
To peel back the layers and see what’s happening under the hood, you would use the --debug=all flag with PyInstaller. This flag instructs PyInstaller to provide verbose output during both the packaging process and the runtime of the executable, offering insights into where things might be going awry.
pyinstaller --onefile --debug=all mystery_behavior.py
By running the executable generated with this command, you can observe detailed logs that may indicate why the configuration file isn’t being read correctly. Perhaps the file path resolution behaves differently in the bundled environment, or there’s an issue with the way the executable accesses external files. Packaging a GUI Application
For those scripts that paint windows and buttons, like simple_gui.py, crafted with the elegance of Tkinter. This example uses Tkinter for a basic GUI, so if you want to test this example, make sure you have Tkinter installed.
Python Script (simple_gui.py):
import tkinter as tk
def main(): root = tk.Tk() root.title("Simple GUI") label = tk.Label(root, text="Hello, GUI World!") label.pack() root.mainloop()
if name == "main": main()
Shell Command:
pyinstaller --onefile --windowed simple_gui.py
The Spec File: A Blueprint for Customization
For the architects seeking to construct their executable with precision, the .spec file is your blueprint. This file, born from your first PyInstaller command, outlines the structure of your executable's package. Editing this file allows for meticulous customizations, from including data files in specific folders to setting that perfect icon that speaks your app's essence.
First, you need to generate a .spec file for your project. If you haven't done this already, run PyInstaller with your script:
pyinstaller your_script.py
This command creates a .spec file named after your script (e.g., your_script.spec) in the same directory.
To edit the .spec file you need to open the .spec file in a text editor. Here are some of the key areas you might customize:
pathex: Specifies additional paths for PyInstaller to search for Python modules and libraries during the compilation process, not at runtime. It’s about telling PyInstaller where to look to find all the Python code and native libraries your script imports. Use pathex if PyInstaller is not finding some of the Python modules or native libraries during the build process.
binaries: A list of non-Python files needed by the application, such as shared libraries and other binaries.
datas: Specifies additional data files to include in the bundle. Each tuple in the list contains the file’s source and the destination path relative to the app’s root.
hiddenimports: Lists imports that PyInstaller cannot detect automatically, ensuring these modules are included in the executable.
hookspath: Additional paths where PyInstaller should look for hooks. Hooks can tell PyInstaller about hidden imports and other details about specific packages.
runtime_hooks: Scripts that are run at runtime before any other code or module is imported. Useful for setting environment variables or patching the sys module.
Here’s an example modification that includes additional data files and sets a custom icon for a GUI application:
block_cipher = None
a = Analysis(['your_script.py'], pathex=['path_to_your_script'], binaries=[], datas=[('path/to/data/files/*', 'data_files')], hiddenimports=[], hookspath=[], runtime_hooks=[], excludes=[], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, noarchive=False) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE(pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [], name='your_application', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, upx_exclude=[], runtime_tmpdir=None, console=False , icon='path/to/your_icon.ico')
In this example, datas includes all files from path/to/data/files/ and places them in a folder named data_files within the application. The icon option for the EXE function sets a custom icon for the executable.
After editing the .spec file, run PyInstaller with the .spec file to rebuild your application:
pyinstaller your_script.spec
By editing the .spec file, developers can fine-tune how PyInstaller packages their Python application, from including data files and binaries to specifying custom icons and handling complex dependencies. This level of customization ensures that the standalone executable meets the specific requirements and behaviors of your application. Conclusion
PyInstaller is a versatile tool that can transform your Python scripts into standalone executables with ease. Whether you’re a beginner looking to distribute your first Python application or an advanced user needing to package complex projects, PyInstaller offers a range of options to suit your needs. Remember to leverage the PyInstaller documentation for in-depth guidance on its vast array of features. Your Support Means a Lot! 🙌
If you enjoyed this article and found it valuable, please consider giving it a clap to show your support. Feel free to explore my other articles, where I cover a wide range of topics related to Python programming and others. By following me, you’ll stay updated on my latest content and insights. I look forward to sharing more knowledge and connecting with you through future articles. Until then, keep coding, keep learning, and most importantly, enjoy the journey!
Happy programming! References PyInstaller Manual - PyInstaller 6.4.0 documentation PyInstaller is tested against Windows, MacOS X, and Linux. However, it is not a cross-compiler; to make a Windows app…
pyinstaller.org
API is een afkorting voor Application Programming Interface en met API doc doelen programmeurs op een beschrijving van functionaliteit ('endpoints') en hoe die functionaliteit kan benaderen. API doc beschrijven dus alle functies en/of methoden, precondities zoals hardware ('resources'), parameters en eventuele as well as examples of common requests and responses. Mijn kennis over APIdoc is oud, ik ken het van mijn ITA payment terminal JAVA ontwikkeling bij Technolution, waar wij JavaDoc gebruikten.
Net dus als Java en C++, heeft Python ook de mogelijkheid om documentatie over de code automatisch , d.w.z. op basis van commentaar in de code, te genereren. Een korte rondgang op Internet leert dat er veel verschillende manieren met bijbehorende tools bestaan. Omdat noch mijn ervaring noch mijn kennis up-to-date is heb ik min of meer het eerste dat ik i.c.m. met het woord Python ik tegenkwam gekozen. In dit geval heb ik blind en direct voor Sphinx gekozen.
pip install Sphinx
- Ga in de root directory van de docs tree. In dit geval is dat
C:\github\labcontrol\src - Genereer het noodzakelijke conf.py. Dit kan via een kant en klare script:
sphinx-quickstart .\docs - Bewerk de gegenereerde conf.py bestand. Stel benodigde zaken in zoals: working directories, welke thema, wat wil je niet meenemen. Zie hieronder en zie vooral de links.
- Genereer documentatie op basis van de code:
sphinx-apidoc -o ./docs/source ./src - Bouw de documentatie. Dat kan via het commando:
sphinx-build -M html docs/source/ docs/build/En als het goed is worden de html bestanden in de subdir 'build' gezet.
In het begin vond ik de omgang met Sphinx ronduit geklier. Een klein missertje in de configuratie levert absoluut geen apidoc op maar wel enorm veel errors. Gelukkig is er een site met goede instructies:https://sphinx-rtd-tutorial.readthedocs.io/en/latest/index.html Er is flink geoefend worden met de bij de tutorial behorende Github repo en daarna met de code in labcontrol repo. Belangrijkste conclusies na best wel veel geklier zijn:
- Alle, in de code aangehaalde imports moeten bestaan, anders gaat Sphinx op z'n bek. Een klein hikje in het proces en het gaat mis, waardoor er geen enkele docu wordt gegenereerd.
- Sphinx logt, maar die log wordt ergens ver weg in een obscure directory gedrukt (Eerder een Windhoos probleem). In console output wordt locatie van het log bestand aangegeven. Even goed zoeken.
- Er bestaat (waarschijnlijk) een verschil tussen het generen van de doc op de lokale (Windows 11) computer en de generatie online op de ReadTheDocs server. Voor lokale generatie moet je de Python runtime voeden met het juiste pad, anders kan Sphinx de code niet vinden tijdens generatie. Vreemd, want tijdens de auto generatie van de *.rst bestanden zeurt Sphinx nergens over. Om lokale generatie aan de praat te krijgen heb ik het volgende toegevoegd aan mijn conf.py:
# copied some configuration from https://github.com/pyvisa/pyvisa/blob/main/docs/source/conf.py
import os
import sys
#sys.path.insert(0, os.path.abspath('../../labcontrol/'))
sys.path.insert(0, os.path.abspath('../..'))
sys.path.append(os.path.abspath('../../labcontrol'))
sys.path.append(os.path.abspath('../../labcontrol/devices'))Bij het voorbeeld is dit ook het geval. De BLE import mislukt op Windows, waardoor Sphinx misgaat. Er is iets als een mockup configuratie, dat valt nog eens te testen, om te kijken of je niet alle refs hoeft te verwijderen in de code, zie https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html#confval-autodoc_mock_imports. Om de mislukte BLE import te onderdrukken voeg je het volgende aan conf.py toe:
autodoc_mock_imports = ['bluepy']
Belangrijk: je moet in index.rst op de juiste manier de docu subpaginas er in hangen, anders zie je wel wat maar ook niet veel.
Ik heb een login aangemaakt en het dashboard van mijn account vind je hier
De html die door quickstart wordt gemaakt is niet heel mooi. Readthedocs van bijvoorbeeld pyvisa ziet er veel beter uit. Dat lijkt, voor zover ik nu kan zeggen, door twee dingen te komen:
- De configuratie in conf.py. Zie hier een voorbeeld van scikit-rf.
- De setup van index.rst en regelateerde bestanden. Zie hier een voorbeeld van index.rst van scikit-rf. Wat opvalt is dat hier gewerkt wordt met referenties naar (zelf geschreven) subpagina's waarin ook sphinx commando's staan. Mijn voornemen is om op dergelijke wijze te verwerken in mijn eigen setup.