nazwa skryptu, moduły i stos wywołania

Zakres

robiąc poprzedni wpis stwierdziłem, że największym problemem było pobranie nazwy skryptu uruchamiającego i spodziewałem się, że już jest to ogarnięte. chwilę potem znalazłem dodatkowe scenariusze udowadniające, że sprawa jest jeszcze trudniejsza i bardziej pogmatwana.

temat wydaje się prosty: uruchamiamy skrypt 'skrypt.ps1′ i chcemy założyć plik logu '_skrypt.log’. przy normalnym wywołaniu nie ma najmniejszego problemu. do wyboru mamy kilka zmiennych automatycznych. główną taką zmienną jest $PSScript oraz linqjące do poszczególnych gałęzi skróty:

 

  • $PSCmdlet
  • $MyInvocation ($PSCmdlet.MyInvocation)
  • $PSScriptRoot
  • $PSCommandPath ($MyInvocation.MyCommand.Path)

hint: aby $PSCmdlet zainicjowało się prawidłowo, należy włączyć advanced functions np. przy pomocy cmdletbinding().

w czym zatem problem? otóż $PScmdlet jest inicjowany w momencie ładowania skryptu, i jest zmienną lokalną. mamy więc wiele scenariuszy, w których zamienna przyjmie różne wartości:

  • uruchomione bezpośrednio z linii poleceń (nie inicjuje zmiennych automatycznych, ale to co uruchamiamy zwróci wartość)
  • uruchomione z funkcji, zdefiniowanej w ramach tego samego pliq
  • uruchomione z funkcji, która ładuję bibliotekę
  • uruchomione z funkcji która uruchamia funkcję z innej biblioteki

aby to unaocznić przygotowałem scenariusz testowy – dostępny na GH, jeśli ktoś chciałby się pobawić samemu. jest tu dużo ciekawych niuansów i zabawy (np. niedostępność wartości $MyInvocation podczas debugowania).

hint: jeśli odpalisz plik test-context.ps1 z tego samego katalogu gdzie są moduły testowe, nie musisz ich instalować.

czy zatem nie da się wyciągnąć informacji o faktycznym źródle wywołania?

get-PSCallStack

da się – przy pomocy funkcji get-PSCallStack. Funkcja ta zwraca … właśnie 'call stack’ czyli stos wywołania. przypatrzmy się, co konkretnie zwraca funkcja, zależnie od kontextu uruchomienia

  • bezpośrednio z linii poleceń:
C:\_ScriptZ :))o- Get-PSCallStack

Command Arguments Location
------- --------- --------
<ScriptBlock> {} <No file>
  • z linii poleceń, wywołanie funkcji z modułu
C:\_ScriptZ :))o- level1-callstack

Command Arguments Location
------- --------- --------
level1-callstack {} level1module.psm1: line 18
<ScriptBlock> {} <No file>
  • ze skryptu:
C:\...PSCmdletTest :))o- .\test-contexts.ps1
WARNING: The names of some imported commands from the module 'level1module' include unapproved verbs that might make them less discoverable. To find the
commands with unapproved verbs, run the Import-Module command again with the Verbose parameter. For a list of approved verbs, type Get-Verb.
PSCallStack in different contexts

directly from here

Command Arguments Location
------- --------- --------
test-contexts.ps1 {} test-contexts.ps1: line 8
<ScriptBlock> {} <No file>


from the function loaded by module

Command Arguments Location
------- --------- --------
level1-callstack {} level1module.psm1: line 18
test-contexts.ps1 {} test-contexts.ps1: line 11
<ScriptBlock> {} <No file>


from the function in module L2 calling function in module L1

Command Arguments Location
------- --------- --------
level1-callstack {} level1module.psm1: line 18
level2-callstack {} level2module.psm1: line 11
test-contexts.ps1 {} test-contexts.ps1: line 13
<ScriptBlock> {} <No file>


MtInvocation test from different contexts

first in-script PScommandPath (from myinvocation)
local myinvocation -> C:\_scriptz\PUBLIC-GITHUB\PSCmdletTest\test-contexts.ps1
now PScommandpath but run from the function, loaded in a module L1 ($myinvocation.command.path is empty for functions)
f1 PScommandPath -> C:\_scriptz\PUBLIC-GITHUB\PSCmdletTest\level1module.psm1
similar, but function inside module L1 calls function in the same module
f1 via f2 PScommandPath -> C:\_scriptz\PUBLIC-GITHUB\PSCmdletTest\level1module.psm1
more complex - call function in module L2, which is calling function in module L1
f1 via diferent module -> C:\_scriptz\PUBLIC-GITHUB\PSCmdletTest\level1module.psm1

to co się jako pierwsze rzuca w oczy, równocześnie spójne z definicją 'stosu wykonania’, to że wielkość stosu jest równa zagnieżdżeniu wywołania. z linii poleceń – jest tylko jeden element, bezpośrednio ze skryptu: 2, z funkcji w skrypcie: 3, a z funkcji wołającej funkcję: 4.

drugim elementem rzucającym się w oczy jest kolumna 'location’ która pokazuje ścieżkę do pliq w którym nastąpiło wywołanie, wraz z linią. jest to stos, ale żeby nie było za łatwo – odwrócony. kolejność elementów jest zawsze taka sama ale przez odwrócenie, trzeba zrobić trochę matematyki:

  • zero (na dnie): ostatnie wywołanie
  • ostatnie-1 : właściwy skrypt wywołujący
  • ostatnie: wywołanie bezpośrednie, linia hosta

i te dwie informacje dają nam już możliwość weryfikacji jak dana funkcja jest wywoływana (bezpośrednio z linii poleceń czy gdzieś ze skryptu) oraz dokładne sprawdzenie czy był to moduł, który, a może po prostu jakaś funkcja.

podsumowanie

funkcja get-pscallstack nie jest specjalnie popularna więc nie łatwo było na nią trafić. jednak przy pisaniu modułów lub jakiejś logiki wymagającej wiedzy o środowisq uruchomienia – okazuje się być dużo bardziej przydatna, niż niekontrolowalne $myInvocation, które zmieniają kontext i wartości.

eN.

install-module eNLib

install-module eNLib

nie tylko udało mi się skończyć* moduł eNLib, ale również opublikować w PowerShell Gallery. przy okazji tego drobnego sukcesu chciałem podzielić się kilkoma doświadczeniami z pisania modułów. wszędobylska zasada Pareto (lub dla bardziej dociekliwych – prawo Zipfa) okazała się sporym wyzwaniem – im bliżej końca, tym trudniej. dla tego słowo opisu samej biblioteki, oraz z czym były największe problemy przy jego wykończeniu.

tak, że teraz instalacja biblioteki sprowadza się do trywialnego 'install-module eNLib’ a źródła można również pobrać z GitHub.

[*skończyć w metodyce CI/CD, czyli najważniejsze gotowe, a z resztą jakoś to będzie (; ]

eNLib

typowa biblioteka użytkowa – kiedy skryptów pisze się dużo, bardzo szybko okazuje się, że pewne funkcje ułatwiające życie, wykorzystuje się w niemal każdym skrypcie. dla mnie taką funkcją jest write-log – funkcja, która forkuje wszelkie informacje zarówno na ekran jak do pliq. dzięki temu nie muszę zastanawiać się nad 'verbose’ [który ma swoje wady] i mam gwarancję, że wszystko co widzę, będzie również w logu. koniec zastanawiania się, że jakiś błąd śmignął na ekranie i nie wiadomo co to. łatwiej też troubleshootować – 'prześlij mi log z przebiegu’ i wszystko będzie jasne.

żeby logować do pliq, należy go zainicjalizować. i z tym było najwięcej zabawy, o czym za chwilę. drugą (czy też raczej zerową) funkcją, jest zatem start-logging, inicjalizująca zmienną $logFile i sam plik logu. jeśli wywoła się write-log bez uprzedniej inicjalizacji – zostanie automatycznie wykonana. wywołanie jawne pozwala na użycie dodatkowych atrybutów – założenie logu o zadanej nazwie, w zadanym folderze lub w katalogu 'documents’ użytkownika.

każdy plik logu zawiera podstawowe informacje dot. środowiska uruchomieniowego, każdy wpis ma timestamp a dodatkowo można tagować linijki błędów/ostrzeżeń/ok przy pomocy parametru 'type’ – nie tylko pokazuje w innym kolorze, co pomaga podczas pracy bieżącej, ale dodaje tak 'ERROR:’ czy 'INFO:’ w logu, pomagając przeszukiwać go później:

*logging initiated 02/06/2021 09:30:41 in C:\Users\nexor\OneDrive\Documents\Logs\_console-2102060930.log
*script parameters:
<none>
***************************************************
09:30:41> test
09:30:42> ERROR: test error

jasna sprawa, jako wiadomość można podać dowolny obiekt, który zostanie skonwertowany do tekstu. szczegóły oraz przykłady użycia – 'man write-log -examples’, 'man start-logging -full’ – więc nie rozpisuję się.

ponadto moduł zawiera funkcje

  • new-randomPassword – generuje ciąg znaków o zadanej złożoności
  • get-AnswerBox – Formsowe okienko OK/Cancel
  • get-valueFromInputBox – Formsowe okienko pozwalające na wpisanie promptu użytkownika
  • get-ExchangeConnectionStatus – weryfikacja połączenia z Exchange [Ex/EXO]
  • connect-Azure – weryfikacja połączenia z Azure

te funkcje są wersjach słabo przetestowanych i na pewno nie są dopieszczone tak jak write-log, którego po prostu używam nagminnie [już nawet odruchowo wpisuje wrtite-log zamiast write-host (; ]. na pewno powoli będę nad nimi pracował – skoro są opublikowane, to będą potrzebowały trochę więcej atencji.

Z czym było najwięcej zabawy?

z uniwersalnym inicjowaniem pliq logu i tzw. ’scopes’ dla zmiennych. idea jest trywialna, ale w praktyce okazało się to zabójcze. np:
  • jeśli funkcja odpalana jest bezpośrednio z konsoli, to $MyInvocation ma inną wartość niż…
  • …kiedy odpali się ze skryptu. ale co jeśli…
  • …funkcja uruchamiana jest przez inną funkcję, która wywołana jest ze skryptu?

przyjąłem default, że plik logu będzie miał nazwę '_<nazwa-skryptu>-<datestamp>.log’ . i żeby ową nazwę skryptu pobrać, trzeba zastosować inną metodę, zależnie od kontekstu – opisanego wyżej. 3ci opisany przypdek, choć wydaje się być najbardziej złożony, jest równocześnie domyślnym:

  • nie chcemy pamiętać i inicjalizować logu, więc po prostu w skrypcie zamieszczamy 'write-log „coś tam”’
  • funkcja write-log wykrywa, że to pierwsze użycie, więc woła start-logging.
  • dodatkowo obie funkcje są w module, a więc w zewnętrznym skrypcie

zmienne typu $MyInvocation to zmienne automatyczne, tworzone w trakcie egzekucji skryptu. maja więc określony kontekst, czy też zakres (scope) i w każdym będą miały inną, lokalną wartość. do tego nie można założyć konkretnego scenariusza – muszą być obsłużone wszystkie. z tego powodu kawałek inicjalizacji musiał zostać przeniesiony do samego write-log, choć łamie to logikę [powinno być wszystko w start-logging]:

    #if function is called without pre-initialize with start-logging, run it to initialize log.
    if( [string]::isNullOrEmpty($script:logFile) ){
        #these need to be calculated here, as $myinvocation context changes giving library name instead of script
        if( [string]::isNullOrEmpty($MyInvocation.PSCommandPath) ) { #it's run directly from console.
            $scriptBaseName = 'console'
        } else {
            $scriptBaseName = ([System.IO.FileInfo]$($MyInvocation.PSCommandPath)).basename 
        }
        if([string]::isNullOrEmpty($MyInvocation.PSScriptRoot) ) { #it's run directly from console
            $logFolder = [Environment]::GetFolderPath("MyDocuments") + '\Logs'
        } else {
            $logFolder = "$($MyInvocation.PSScriptRoot)\Logs"
        }
        $logFile = "{0}\_{1}-{2}.log" -f $logFolder,$scriptBaseName,$(Get-Date -Format yyMMddHHmm)
        start-Logging -logFileName $logFile
    }
zmienna $logFile jest wykorzystana jako flaga – jeśli nie jest zainicjowana, odpalana jest 'start-logging’. zmienna jest eksportowana, więc można jej potem używać wewnątrz własnych funkcji np. żeby poinformować, 'zapis przebiegu jest zapisany w $logFile’.
drugim problemem/ciekawostką, nad którą chwilę musiałem się pogłowić, było umożliwienie funkcji konsumpcji dowolnej ilości parametrów. znów teoretycznie – sprawa prosta. wystarczy dodać 'ValueFromRemainingArguments=$true’ i pozamiatane…. tak przynajmiej wynikało z licznych przykładów dostępnych w necie. tyle, że te przykłady zawsze pokazywały sytuację, gdzie był tylko jeden parametr – a tu, funkcja ma ich kilka. i za każdym razem wywalało błąd, próbując podstawić pod nie wartość, która miała iść jako 'message’. podobną funkcjonalość ma write-host. wszystkie przykłady zadziałają:
write-log "jakis text"
write-log jakis text
write-log jakis -type ok text
z tą różnicą, że write-host zapisze wszystkie parametry w jednej linii, write-log – w oddzielnych liniach. zgubna okazała się … poprawność zapisu parametrów. kiedy deklaracja nagłówka była 'pełna’, przekazywanie nie działało prawidłowo:
    param(
        #message to display - can be an object
        [parameter(ValueFromRemainingArguments=$true,mandatory=$false,position=0)]
            $message,
        #adds description and colour dependently on message type
        [parameter(mandatory=$false,position=1)]
            [string][validateSet('error','info','warning','ok')]$type,
        #do not output to a screen - logfile only
        [parameter(mandatory=$false,position=2)]
            [switch]$silent,
        # do not show timestamp with the message
        [Parameter(mandatory=$false,position=3)]
            [switch]$skipTimestamp
    )
natomiast po usunięciu deklaracji prametrów udało się uzyskać porządany efekt:
    param(
        #message to display - can be an object
        [parameter(ValueFromRemainingArguments=$true,mandatory=$false,position=0)]
            $message,
        #adds description and colour dependently on message type
            [string][validateSet('error','info','warning','ok')]$type,
        #do not output to a screen - logfile only
            [switch]$silent,
        # do not show timestamp with the message
            [switch]$skipTimestamp
    )
nie wiem czy 'pedanteryjna’ wersja deklaracji pozwalająca na pożądany efekt, a póki co nie udało mi się znaleźć sposobu na zdebugowanie write-host. ale pewnie kiedyś poświęcę czas, żeby go podejrzeć i przeanalizować.

2o/8o

choć write-log używam od dawien-dawna, czyli większość pracy niby była zrobiona, okazało się, że to jest to 2o% pracy, robiące 8o% wyniq. kiedy zacząłem robić z tego moduł, z myślą o publikacji, pozostałe 8o% pracy, stanowiące 2o% tego, co widać to:
  • testy, testy i jeszcze raz testy – w różnych konfiguracjach. i tak nie zrobiłem wszystkich – choćby z wersji core.
  • opisanie funkcji wraz z przykładami.
  • zapanowanie nad inicjalizacją logu bez względu na sposób uruchomienia
  • prawidłowe przygotowanie do publikacji jako moduł
mam nadzieję, że komuś ułatwi pracę (:
eN.