Designing Help Documentation for Windows PowerShell Scripts

  • 12/16/2009

The 13 Rules for Writing Effective Comments

When adding documentation to a script, it is important that you do not introduce errors. If the comments and code do not match, there is a good chance that both are wrong. Make sure that when you modify the script, you also modify your comments. In this way, both the comments and the script refer to the same information.

Update Documentation When a Script Is Updated

It is easy to forget to update comments that refer to the parameters of a function when you add additional parameters to that function. In a similar fashion, it is easy to ignore the information contained inside the header of the script that refers to dependencies or assumptions within the script. Make sure that you treat both the script and the comments with the same level of attention and importance. In the FindDisabledUserAccounts.ps1 script, the comments in the header seem to apply to the script, but they also seem to miss the fact that the script is using the [ADSISearcher] type accelerator. In fact, the script is a modified script that was used to create a specific instance of the DirectoryServices.DirectorySearcher .NET Framework class and was recently updated. However, the comments were never updated. This oversight might make a user suspicious as to the accuracy of a perfectly useful script. The FindDisabledUserAccounts.ps1 script is shown here.

Example 10-10. FindDisabledUserAccounts.ps1

# ------------------------------------------------------------------------
# FindDisabledUserAccounts.ps1
# ed wilson, 3/28/2008
#
# Creates an instance of the DirectoryServices DirectorySearcher .NET
# Framework class to search Active Directory.
# Creates a filter that is LDAP syntax that gets applied to the searcher
# object. If we only look for class of user, then we also end up with
# computer accounts as they are derived from user class. So we do a
# compound query to also retrieve person.
# We then use the findall method and retrieve all users.
# Next we use the properties property and choose item to retrieve the
# distinguished name of each user, and then we use the distinguished name
# to perform a query and retrieve the UAC attribute, and then we do a
# boolean to compare with the value of 2 which is disabled.
#
# ------------------------------------------------------------------------
#Requires -Version 2.0

$filter = "(&(objectClass=user)(objectCategory=person))"
$users = ([adsiSearcher]$Filter).findall()

 foreach($suser in $users)
  {
   "Testing $($suser.properties.item(""distinguishedname""))"
   $user = [adsi]"LDAP://$($suser.properties.item(""distinguishedname""))"

   $uac=$user.psbase.invokeget("useraccountcontrol")
     if($uac -band 0x2)
       { write-host -foregroundcolor red "`t account is disabled" }
     ELSE
       { write-host -foregroundcolor green "`t account is not disabled" }
  } #foreach

Add Comments During the Development Process

When you are writing a script, make sure that you add the comments at the same time you are doing the initial development. Do not wait until you have completed the script to begin writing your comments. When you make comments after writing the script, it is very easy to leave out details because you are now overly familiar with the script and those items that you looked up in documentation now seem obvious. If you add the comments at the same time that you write the script, you can then refer to these comments as you develop the script to ensure that you maintain a consistent approach. This procedure will help with the consistency of your variable names and writing style. The CheckForPdfAndCreateMarker.ps1 script illustrates this consistency problem. In reviewing the code, it seems that the script checks for PDF files, which also seems rather obvious from the name of the script. However, why is the script prompting to delete the files? What is the marker? The only discernable information is that I wrote the script back in December 2008 for a Hey Scripting Guy! article. Luckily, Hey Scripting Guy! articles explain scripts, so at least some documentation actually exists! The CheckForPdfAndCreateMarker.ps1 script is shown here.

Example 10-11. CheckForPdfAndCreateMarker.ps1

# -----------------------------------------------------------------------------------
# CheckForPdfAndCreateMarker.ps1
# ed wilson, msft, 12/11/2008
#
# Hey Scripting Guy! 12/29/2008
# -----------------------------------------------------------------------------------
$path = "c:\fso"
$include = "*.pdf"
$name = "nopdf.txt"
if(!(Get-ChildItem -path $path -include $include -Recurse))
  {
    "No pdf was found in $path. Creating $path\$name marker file."
    New-Item -path $path -name $name -itemtype file -force |
    out-null
  } #end if not Get-Childitem
ELSE
 {
  $response = Read-Host -prompt "PDF files were found. Do you wish to delete <y>
/<n>?"
  if($response -eq "y")
    {
     "PDF files will be deleted."
     Get-ChildItem -path $path -include $include -recurse |
      Remove-Item
    } #end if response
  ELSE
   {
    "PDF files will not be deleted."
   } #end else reponse
 } #end else not Get-Childitem

Write for an International Audience

When you write comments for your script, you should attempt to write for an international audience. You should always assume that users who are not overly familiar with the idioms of your native language will be reading your comments. In addition, writing for an international audience makes it easier for automated software to localize the script documentation. Key points to keep in mind when writing for an international audience are to use a simple syntax and to use consistent employee standard terminology. Avoid slang, acronyms, and overly familiar language. If possible, have a colleague who is a non-native speaker review the documentation. In the SearchForWordImages.ps1 script, the comments explain what the script does and also its limitations, such as the fact that it was only tested using Microsoft Office Word 2007. The sentences are plainly written and do not use jargon or idioms. The SearchForWordImages.ps1 script is shown here.

Example 10-12. SearchForWordImages.ps1

# ------------------------------------------------------------------------
# NAME: SearchForWordImages.ps1
# AUTHOR: ed wilson, Microsoft
# DATE: 11/4/2008
#
# KEYWORDS: Word.Application, automation, COM
# Get-Childitem -include, Foreach-Object
#
# COMMENTS: This script searches a folder for doc and
# docx files, opens them with Word and counts the
# number of images embedded in the file.
# It then prints out the name of each file and the
# number of associated images with the file. This script requires
# Word to be installed. It was tested with Word 2007. The folder must
# exist or the script will fail.
#
# ------------------------------------------------------------------------
#The folder must exist and be followed with a trailing \*
$folder = "c:\fso\*"
$include = "*.doc","*.docx"
$word = new-object -comobject word.application
#Makes the Word application invisible. Set to $true to see the application.
$word.visible = $false
Get-ChildItem -path $folder -include $include |
ForEach-Object `
{
 $doc = $word.documents.open($_.fullname)
 $_.name + " has " + $doc.inlineshapes.count + " images in the file"
}
#If you forget to quit Word, you will end up with multiple copies running
#at the same time.
$word.quit()

Consistent Header Information

You should include header information at the top of each script. This header information should be displayed in a consistent manner and indeed should be part of your company’s scripting standards. Typical information to be displayed is the title of the script, author of the script, date the script was written, version information, and additional comments. Version information does not need to be more extensive than the major and minor versions. This information, as well as comments as to what was added during the revisions, is useful for maintaining a version control for production scripts. An example of adding comments is shown in the WriteBiosInfoToWord.ps1 script.

Example 10-13. WriteBiosInfoToWord.ps1

# =============================================================================
#
# NAME: WriteBiosInfoToWord.ps1
#
# AUTHOR: ed wilson , Microsoft
# DATE : 10/30/2008
# EMAIL: Scripter@Microsoft.com
# Version: 1.0
#
# COMMENT: Uses the word.application object to create a new text document
# uses the get-wmiobject cmdlet to query wmi
# uses out-string to remove the "object nature" of the returned information
# uses foreach-object cmdlet to write the data to the word document.
#
# Hey Scripting Guy! 11/11/2008
# =============================================================================

$class = "Win32_Bios"
$path = "C:\fso\bios"

#The wdSaveFormat object must be saved as a reference type.
[ref]$SaveFormat = "microsoft.office.interop.word.WdSaveFormat" -as [type]

$word = New-Object -ComObject word.application
$word.visible = $true
$doc = $word.documents.add()
$selection = $word.selection
$selection.typeText("This is the bios information")
$selection.TypeParagraph()

Get-WmiObject -class $class |
Out-String |
ForEach-Object { $selection.typeText($_) }
$doc.saveas([ref] $path, [ref]$saveFormat::wdFormatDocument)
$word.quit()

Document Prerequisites

It is imperative that your comments include information about prerequisites for running the script as well as the implementation of nonstandard programs in the script. For example, if your script requires the use of an external program that is not part of the operating system, you need to include checks within the script to ensure that the program is available when it is called by the script itself. In addition to these checks, you should document the fact that the program is a requirement for running the script. If your script makes assumptions as to the existence of certain directories, you should make a note of this fact. Of course, your script should use Test-Path to make sure that the directory exists, but you should still document this step as an important precondition for the script. An additional consideration is whether or not you create the required directory. If the script requires an input file, you should add a comment that indicates this requirement as well as add a comment to check for the existence of the file prior to actually calling that file. It is also a good idea to add a comment indicating the format of the input file because one of the most fragile aspects of a script that reads an input file is the actual formatting of that file. The ConvertToFahrenheit_include.ps1 script illustrates adding a note about the requirement of accessing the include file.

Example 10-14. ConvertToFahrenheit_include.ps1

# ------------------------------------------------------------------------
# NAME: ConvertToFahrenheit_include.ps1
# AUTHOR: ed wilson, Microsoft
# DATE: 9/24/2008
# EMAIL: Scripter@Microsoft.com
# Version 2.0
#   12/1/2008 added test-path check for include file
#             modified the way the include file is called
# KEYWORDS: Converts Celsius to Fahrenheit
#
# COMMENTS: This script converts Celsius to Fahrenheit
# It uses command line parameters and an include file.
# If the ConversionFunctions.ps1 script is not available,
# the script will fail.
#
# ------------------------------------------------------------------------
Param($Celsius)
#The $includeFile variable points to the ConversionFunctions.ps1
#script. Make sure you edit the path to this script.
$includeFile = "c:\data\scriptingGuys\ConversionFunctions.ps1"
if(!(test-path -path $includeFile))
  {
   "Unable to find $includeFile"
   Exit
  }
. $includeFile
ConvertToFahrenheit($Celsius)

Document Deficiencies

If the script has a deficiency, it is imperative that this is documented. This deficiency may be as simple as the fact that the script is still in progress, but this fact should be highlighted in the comments section of the header to the script. It is quite common for script writers to begin writing a script, become distracted, and then begin writing a new script, all the while forgetting about the original script in progress. When the original script is later found, someone might begin to use the script and be surprised that it does not work as advertised. For this reason, scripts that are in progress should always be marked accordingly. If you use a keyword, such as in progress, then you can write a script that will find all of your work-in progress scripts. In addition to scripts in progress, you should also highlight any limitations of the script. If a script runs on a local computer but will not run on a remote computer, this fact should be added in the comment section of the header. If a script requires an extremely long time to complete the requested action, this information should be noted. If the script generates errors but completes its task successfully, this information should also be noted so that the user can have confidence in the outcome of the script. A note that indicates why the error is generated also increases the confidence of the user in the original writer. The CmdLineArgumentsTime.ps1 script works but generates errors unless it is used in a certain set of conditions and is called in a specific manner. The comments call out the special conditions, and several INPROGRESS tags indicate the future work required by the script. The CmdLineArgumentsTime.ps1 script is shown here.

Example 10-15. CmdLineArgumentsTime.ps1

# ===========================================================================
#
# NAME: CmdLineArgumentsTime.ps1
# AUTHOR: Ed Wilson , microsoft
# DATE  : 2/19/2009
# EMAIL: Scripter@Microsoft.com
# Version .0
# KEYWORDS: Add-PSSnapin, powergadgets, Get-Date
#
# COMMENT: The $args[0] is unnamed argument that accepts command line input.
# C:\cmdLineArgumentsTime.ps1 23 52
# No commas are used to separate the arguments. Will generate an error if used.
# Requires powergadgets.
# INPROGRESS: Add a help function to script.
# ===========================================================================
#INPROGRESS: change unnamed arguments to a more user friendly method
[int]$inthour = $args[0]
[int]$intMinute = $args[1]
#INPROGRESS: find a better way to check for existence  of powergadgets
#This causes errors to be ignored and is used when checking for PowerGadgets
$erroractionpreference = "SilentlyContinue"
#this clears all errors and is used to see if errors are present.
$error.clear()
#This command will generate an error if PowerGadgets are not installed
Get-PSSnapin *powergadgets | Out-Null
#INPROGRESS: Prompt before loading powergadgets
If ($error.count -ne 0)
{Add-PSSnapin powergadgets}

New-TimeSpan -Start (get-date) -end (get-date -Hour $inthour -Minute $intMinute) |
Out-Gauge -Value minutes -Floating -refresh 0:0:30  -mainscale_max 60

Avoid Useless Information

Inside the code of the script itself, you should avoid comments that provide useless or irrelevant information. Keep in mind that you are writing a script and providing documentation for the script and that such a task calls for technical writing skills, not creative writing skills. While you might be enthralled with your code in general, the user of the script is not interested in how difficult it was to write the script. However, it is useful to explain why you used certain constructions instead of other forms of code writing. This information, along with the explanation, can be useful to people who might modify the script in the future. You should therefore add internal comments only if they will help others to understand how the script actually works. If a comment does not add value, the comment should be omitted. The DemoConsoleBeep.ps1 script contains numerous comments in the body of the script. However, several of them are obvious, and others actually duplicate information from the comments section of the header. There is nothing wrong with writing too many comments, but it can be a bit excessive when a one-line script contains 20 lines of comments, particularly when the script is very simple. The DemoConsoleBeep.ps1 script is shown here.

Example 10-16. DemoConsoleBeep.ps1

# ------------------------------------------------------------------------
# NAME: DemoConsoleBeep.ps1
# AUTHOR: ed wilson, Microsoft
# DATE: 4/1/2009
#
# KEYWORDS: Beep
#
# COMMENTS: This script demonstrates using the console
# beep. The first parameter is the frequency between
# 37..32767. above 7500 is barely audible. 37 is the lowest
# note it will play.
# The second parameter is the length of time
#
# ------------------------------------------------------------------------
#this construction creates an array of numbers from 37 to 3200
#the % sign is an alias for Foreach-Object
#the $_ is an automatic variable that refers to the current item
#on the pipeline.
#the semicolon causes a new logical line
#the double colon is used to refer to a static method
#the $_ in the method is the number on the pipeline
#the second number is the length of time to play the beep
37..32000 | % { $_ ; [console]::beep($_ , 1) }

Document the Reason for the Code

While it is true that good code is readable and that a good developer is able to understand what a script does, some developers might not understand why a script is written in a certain manner or why a script works in a particular fashion. In the DemoConsoleBeep2.ps1 script, extraneous comments have been removed. Essential information about the range that the console beep will accept is included, but the redundant information is deleted. In addition, a version history is added because significant modification to the script was made. The DemoConsoleBeep2.ps1 script is shown here.

Example 10-17. DemoConsoleBeep2.ps1

# ------------------------------------------------------------------------
# NAME: DemoConsoleBeep2.ps1
# AUTHOR: ed wilson, Microsoft
# DATE: 4/1/2009
# VERSION 2.0
# 4/4/2009 cleaned up comments. Removed use of % alias. Reformatted.
#
# KEYWORDS: Beep
#
# COMMENTS: This script demonstrates using the console
# beep. The first parameter is the frequency. Allowable range is between
# 37..32767. A number above 7500 is barely audible. 37 is the lowest
# note the console beep will play.
# The second parameter is the length of time.
#
# ------------------------------------------------------------------------

37..32000 |
Foreach-Object { $_ ; [console]::beep($_ , 1) }

Use of One-Line Comments

You should use one-line comments that appear prior to the code that is being commented to explain the specific purpose of variables or constants. You should also use one-line comments to document fixes or workarounds in the code as well as to point to the reference information explaining these fixes or workarounds. Of course, you should strive to write code that is clear enough to not require internal comments. Do not add comments that simply repeat what the code already states. Add comments to illuminate the code but not to elucidate the code. The GetServicesInSvchost.ps1 script uses comments to discuss the logic of mapping the handle property from the Win32_Process class to the ProcessID property from the Win32_Service WMI class to reveal which services are using which instance of the Svchost process. The GetServicesInSvchost.ps1 script is shown here.

Example 10-18. GetServicesInSvchost.ps1

# ------------------------------------------------------------------------
# NAME: GetServicesInSvchost.ps1
# AUTHOR: ed wilson, Microsoft
# DATE: 8/21/2008
#
# KEYWORDS: Get-WmiObject, Format-Table,
# Foreach-Object
#
# COMMENTS: This script creates an array of WMI process
# objects and retrieves the handle of each process object.
# According to MSDN the handle is a process identifier. It
# is also the key of the Win32_Process class. The script
# then uses the handle which is the same as the processID
# property from the Win32_service class to retrieve the
# matches.
#
# HSG 8/28/2008
# ------------------------------------------------------------------------

$aryPid = @(Get-WmiObject win32_process -Filter "name='svchost.exe'") |
  Foreach-Object { $_.Handle }

"There are " + $arypid.length + " instances of svchost.exe running"

foreach ($i in $aryPID)
{
 Write-Host "Services running in ProcessID: $i" ;
 Get-WmiObject win32_service -Filter " processID = $i" |
 Format-Table name, state, startMode
}

Avoid End-of-Line Comments

You should avoid using end-of-line comments. The addition of such comments to your code has a severely distracting aspect to structured logic blocks and can cause your code to be more difficult to read and maintain. Some developers try to improve on this situation by aligning all of the comments at a particular point within the script. While this initially looks nice, it creates a maintenance nightmare because each time the code is modified, you run into the potential for a line to run long and push past the alignment point of the comments. When this occurs, it forces you to move everything over to the new position. Once you do this a few times, you will probably realize the futility of this approach to commenting internal code. One additional danger of using end-of-line comments when working with Windows PowerShell is that, due to the pipelining nature of language, a single command might stretch out over several lines. Each line that ends with a pipeline character continues the command to the next line. A comment character placed after a pipeline character will break the code as shown here, where the comment is located in the middle of a logical line of code. This code will not work.

Get-Process | #This cmdlet obtains a listing of all processes on the computer
Select-Object -property name

A similar situation also arises when using the named parameters of the ForEach-Object cmdlet as shown in the SearchAllComputersInDomain.ps1 script. The backtick (`) character is used for line continuation, which allows placement of the –Begin, –Process, and –End parameters on individual lines. This placement makes the script easier to read and understand. If an end-of-line comment is placed after any of the backtick characters, the script will fail. The SearchAllComputersInDomain.ps1 script is shown here.

Example 10-19. SearchAllComputersInDomain.ps1

$Filter = "ObjectCategory=computer"
$Searcher = New-Object System.DirectoryServices.DirectorySearcher($Filter)
$Searcher.Findall() |
Foreach-Object `
  -Begin { "Results of $Filter query: " } `
  -Process { $_.properties ; "`r"} `
  -End { [string]$Searcher.FindAll().Count + " $Filter results were found" }

Document Nested Structures

The previous discussion about end-of-line comments should not be interpreted as dismissing comments that document the placement of closing curly brackets. In general, you should avoid creating deeply nested structures, but sometimes they cannot be avoided. The use of end-of-line comments with closing curly brackets can greatly improve the readability and maintainability of your script. As shown in the Get-MicrosoftUpdates.ps1 script, the closing curly brackets are all tagged.

Example 10-20. Get-MicrosoftUpdates.ps1

# ------------------------------------------------------------------------
# NAME: Get-MicrosoftUpdates.ps1
# AUTHOR: ed wilson, Microsoft
# DATE: 2/25/2009
#
# KEYWORDS: Microsoft.Update.Session, com
#
# COMMENTS: This script lists the Microsoft Updates
# you can select a certain number, or you can choose
# all of the updates.
#
# HSG 3-9-2009
# ------------------------------------------------------------------------
Function Get-MicrosoftUpdates
{
  Param(
        $NumberOfUpdates,
        [switch]$all
       )
  $Session = New-Object -ComObject Microsoft.Update.Session
  $Searcher = $Session.CreateUpdateSearcher()
  if($all)
    {
      $HistoryCount = $Searcher.GetTotalHistoryCount()
      $Searcher.QueryHistory(1,$HistoryCount)
    } #end if all
  Else
    {
      $Searcher.QueryHistory(1,$NumberOfUpdates)
    } #end else
} #end Get-MicrosoftUpdates

# *** entry point to script ***

# lists the latest update
# Get-MicrosoftUpdates -NumberofUpdates 1

# lists All updates
Get-MicrosoftUpdates -all

Use a Standard Set of Keywords

When adding comments that indicate bugs, defects, or work items, you should use a set of keywords that is consistent across all scripts. This would be a good item to add to your corporate scripting guidelines. In this way, a script can easily be developed that will search your code for such work items. If you maintain source control, then a comment can be added when these work items are fixed. Of course, you would also increment the version of the script with a comment relating to the fix. In the CheckEventLog.ps1 script, the script accepts two command-line parameters. One parameter is for the event log to query, and the other is for the number of events to return. If the user selects the security log and is not running the script as an administrator, an error is generated that is noted in the comment block. Because this scenario could be a problem, the outline of a function to check for admin rights has been added to the script as well as code to check for the log name. A number of TODO: tags are added to the script to mark the work items. The CheckEventLog.ps1 script is shown here.

Example 10-21. CheckEventLog.ps1

# ------------------------------------------------------------------------
# NAME: CheckEventLog.ps1
# AUTHOR: ed wilson, Microsoft
# DATE: 4/4/2009
#
# KEYWORDS: Get-EventLog, Param, Function
#
# COMMENTS: This accepts two parameters the logname
# and the number of events to retrieve. If no number for
# -max is supplied it retrieves the most recent entry.
# The script fails if the security log is targeted and it is
# not run with admin rights.
# TODO: Add function to check for admin rights if
# the security log is targeted.
# ------------------------------------------------------------------------
Param($log,$max)
Function Get-log($log,$max)
{
 Get-EventLog -logname $log -newest $max
} #end Get-Log

#TODO: finish Get-AdminRights function
Function Get-AdminRights
{
#TODO: add code to check for administrative
#TODO: rights. If not running as an admin
#TODO: if possible add code to obtain those rights
} #end Get-AdminRights

If(-not $log) { "You must specify a log name" ; exit}
if(-not $max) { $max = 1 }
#TODO: turn on the if security log check
# If($log -eq "Security") { Get-AdminRights ; exit }
Get-Log -log $log -max $max

Document the Strange and Bizarre

The last item that should be commented in your documentation is anything that looks strange. If you use a new type of construction that you have not used previously in other scripts, you should add a comment to the effect. A good comment should also indicate the previous coding construction as an explanation. In general, it is not a best practice to use code that looks strange simply to show your dexterity or because it is an elegant solution; rather, you should strive for readable code. However, when you discover a new construction that is cleaner and easier to read, albeit a somewhat novel approach, you should always add a comment to highlight this fact. If the new construction is sufficiently useful, then it should be incorporated into your corporate scripting guidelines as a design pattern. In the GetProcessesDisplayTempFile.ps1 script, a few unexpected items crop up. The first is the GetTempFileName static method from the Io.Path .NET Framework class. Despite the method’s name, GetTempFileName both creates a temporary file name as well as a temporary file itself. The second technique is much more unusual. When the temporary file is displayed via Notepad, the result of the operation is pipelined to the Out-Null cmdlet. This operation effectively halts the execution of the script until the Notepad application is closed. This “trick” does not conform to expected behavior, but it is a useful design pattern for those wanting to remove temporary files once they have been displayed. As a result, both features of the GetProcessDisplayTempFile.ps1 script are documented as shown here.

Example 10-22. GetProcessesDisplayTempFile.ps1

# ------------------------------------------------------------------------
# NAME: GetProcessesDisplayTempFile.ps1
# AUTHOR: ed wilson, Microsoft
# DATE: 4/4/2009
# VERSION 1.0
#
# KEYWORDS: [io.path], GetTempFileName, out-null
#
# COMMENTS: This script creates a temporary file,
# obtains a collection of process information and writes
# that to the temporary file. It then displays that file via
# Notepad and then removes the temporary file when
# done.
#
# ------------------------------------------------------------------------
#This both creates the file name as well as the file itself
$tempFile = [io.path]::GetTempFileName()
Get-Process >> $tempFile
#Piping the Notepad filename to the Out-Null cmdlet halts
#the script execution
Notepad $tempFile | Out-Null
#Once the file is closed the temporary file is closed and it is
#removed
Remove-Item $tempFile