Session 10 · Computer Systems
PowerShell: files as objects, switch parameters and hashtables
Get-ChildItem returns file objects, so size, date and extension are properties to filter and sort by instead of columns to cut out of text. A hashtable answers "how many per …" and "which has the most …" in a few lines – with a composite key when one field is not enough, or with Group-Object in a pipeline.
Much of PowerShell is a shell like any other with a different spelling: variables, conditions, loops, a pipe. Two things are not. The first is the file system: Get-ChildItem hands you file objects, so a file's size, date and extension are properties you ask for, not columns you cut out of a line of text. The second is the hashtable, a key–value store that turns "how many per …" and "which has the most …" questions into a few lines.
This page takes both from the ground up, together with the script parameters that go with them: switches, default values and arrays. It assumes you can write a basic script – a param() block, Test-Path, a loop over Get-Content; if not, start with the introduction to PowerShell.
The idea in one sentence: stop parsing text when PowerShell already has the value – a file knows its own Length, a date knows how to compare itself, and a hashtable knows whether it has seen a key before.The files used on this page
Downloads
Example and task files
The mixed_files directory – eight small files of different types and sizes – is what the examples below list, filter and sort; the other five are inputs of the tasks. If you download them one by one, put the eight mixed_files/… files into a directory of that name.
mixed_files/READMEmixed_files/app.logmixed_files/archive.TXTmixed_files/big.logmixed_files/data.csvmixed_files/notes.txtmixed_files/prices.csvmixed_files/todo.txtneptun_codes.txtread.meprint_log.txtpatients_messy.txtnumbers_spaces.txt
Or all at once, from the terminal – the files land in the current directory:
Linux, macOS, the course server
curl --create-dirs -o "#1" "https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/{mixed_files/README,mixed_files/app.log,mixed_files/archive.TXT,mixed_files/big.log,mixed_files/data.csv,mixed_files/notes.txt,mixed_files/prices.csv,mixed_files/todo.txt,neptun_codes.txt,read.me,print_log.txt,patients_messy.txt,numbers_spaces.txt}"Windows (PowerShell, cmd)
curl.exe --create-dirs -o "#1" "https://laszlonemes.com/files/computer-systems/powershell-files-hashtables/{mixed_files/README,mixed_files/app.log,mixed_files/archive.TXT,mixed_files/big.log,mixed_files/data.csv,mixed_files/notes.txt,mixed_files/prices.csv,mixed_files/todo.txt,neptun_codes.txt,read.me,print_log.txt,patients_messy.txt,numbers_spaces.txt}"Look at the data files freely, but don't save them from an editor: their line endings and last bytes are part of the exercise, and an editor may "fix" them.
Work in the directory that holds these files, and run your scripts from inside PowerShell 7 (pwsh), not from bash: an array parameter such as txt,csv only arrives as an array when PowerShell itself parses the command line.
Files are objects
In a Unix shell a file listing is text. To get the sizes you have to know that in the output of ls -l the size is the fifth column, cut it out, and hope no file name contains a space. Or you start a separate program for every question: one for the size, another for the date, a third for filtering.
# bash: the size is "the fifth column" of a line of text
ls -l mixed_files | awk '{ print $5, $9 }'Get-ChildItem returns one object per file, and the object's data has names (Fig. 1): $file.Length is a number, $file.LastWriteTime is a date. Nothing has to be counted, cut or converted.
Length property.$file = Get-Item mixed_files/notes.txt
$file.Name # notes.txt
$file.BaseName # notes (the name without the extension)
$file.Extension # .txt (with the dot; an empty string for README)
$file.Length # 60 (bytes – a number, not text)
$file.LastWriteTime # a date object, not a string
$file.FullName # the absolute path| Property | What it holds |
|---|---|
Name | the file name: notes.txt |
BaseName | the name without the extension: notes |
Extension | the extension with the dot: .txt; an empty string if there is none |
FullName | the absolute path |
Length | the size in bytes, as a number |
LastWriteTime | when it was last modified, as a date object |
Get-Item gives the object of one path; Get-ChildItem gives the entries of a directory. By default that includes subdirectories – add -File whenever you mean files, or the first directory in the list breaks your size arithmetic.
Get-ChildItem -Path mixed_files # files and directories
Get-ChildItem -Path mixed_files -File # files only
Get-ChildItem -Path mixed_files -Directory # directories only
Get-ChildItem -Path mixed_files -File -Recurse # files in the whole tree below it
foreach ($file in Get-ChildItem -Path mixed_files -File) {
Write-Output "$($file.Name): $($file.Length) bytes"
}Dates and sizes
LastWriteTime and the result of Get-Date are date objects. They have methods (AddDays, AddHours, ToString with a format) and they compare with the usual operators – "modified in the last 7 days" is a single -ge.
$now = Get-Date # this moment, as a date object
$limit = $now.AddDays(-7) # the same moment, seven days ago
$file.LastWriteTime -ge $limit # True if modified in the last 7 days: dates compare like numbers
$file.LastWriteTime.ToString('yyyy-MM-dd HH:mm') # 2026-11-11 14:45
$file.LastWriteTime = $now.AddDays(-10) # the property can be assigned too: the file is now "ten days old"Sizes are plain numbers of bytes. PowerShell understands the suffixes KB, MB and GB on a number, in a script and on the command line alike:
1KB # 1024
1MB # 1048576
$file.Length -gt 1KB # is the file larger than 1024 bytes?A pipeline of files
Because objects travel through the pipe, every stage can refer to a property by name. Where-Object filters, Sort-Object orders, Select-Object picks properties or the first few items, Measure-Object adds up. Inside the braces, $_ is the object passing through (Fig. 2).
Get-ChildItem -Path mixed_files -File |
Where-Object { $_.Length -gt 50 } |
Sort-Object -Property Length -Descending |
Select-Object -Property Name, LengthName Length
---- ------
big.log 3360
app.log 84
notes.txt 60Length. No stage ever sees a line of text.Get-ChildItem mixed_files -File | Where-Object Length -gt 50 # one property, one comparison: no { } and no $_
Get-ChildItem mixed_files -File | Measure-Object -Property Length -Sum # Count: 8, Sum: 3656When the result goes into a variable, wrap the pipeline in @( ). A pipeline returns an array for several items, the bare item for one, and nothing for none; @( ) makes it an array in all three cases, so the code after it doesn't have to care.
$large = @(Get-ChildItem -Path mixed_files -File | Where-Object { $_.Length -gt 1KB })
$large.Count # 1 – an array with one element; without @( ) it would be a bare file object,
# and with no match at all it would be $null| Question | Pipeline |
|---|---|
| files larger than 1 KB | Get-ChildItem -File | Where-Object { $_.Length -gt 1KB } |
| modified in the last 7 days | … | Where-Object { $_.LastWriteTime -ge (Get-Date).AddDays(-7) } |
| largest first | … | Sort-Object -Property Length -Descending |
| the three largest | … | Sort-Object -Property Length -Descending | Select-Object -First 3 |
| number of files and total size | … | Measure-Object -Property Length -Sum |
| only the .txt files | Get-ChildItem -File -Filter *.txt |
Creating, copying, moving and deleting
The cmdlets that change the file system follow the Verb-Noun pattern and all work on an item – a file or a directory.
| To … | Cmdlet | Note |
|---|---|---|
| create a directory | New-Item -Path users -ItemType Directory | an error if it already exists |
| create it with its parents | New-Item -Path users/a/b -ItemType Directory -Force | no error if it exists |
| test for a directory | Test-Path -Path $dir -PathType Container | for a file: -PathType Leaf |
| copy a file | Copy-Item -Path read.me -Destination users | overwrites a file of the same name |
| copy a directory | Copy-Item -Path src -Destination dst -Recurse | with everything in it |
| move or rename | Move-Item -Path notes.txt -Destination txt | |
| delete | Remove-Item -Path test_dir -Recurse | permanent – there is no recycle bin |
Two details make such a script behave. New-Item returns the object it created, and anything a script returns is printed – pipe it into Out-Null to keep the output clean. And build paths with Join-Path instead of gluing strings with a slash: it uses the separator of the system it runs on.
$userDir = Join-Path -Path $TargetDir -ChildPath $id # users/RVR55V – users\RVR55V on Windows
if (-not (Test-Path -Path $userDir -PathType Container)) { # only if it isn't there yet
New-Item -Path $userDir -ItemType Directory | Out-Null # Out-Null: don't print the new directory
}Aliases: which ones work everywhere
Cmdlets have short names, and at the prompt they save typing: dir for Get-ChildItem, gc for Get-Content, ? for Where-Object, % for ForEach-Object. The aliases fall into two groups, and only one of them is the same on every system.
| Aliases | Where they exist |
|---|---|
gc, dir, gci, echo, cd, copy, move, del, md | everywhere |
?, where, %, foreach, select, measure, group, sls | everywhere |
ls, cat, cp, mv, rm, sort | only on Windows – on Linux and macOS these names start the real Unix programs |
The last group is the trap. On Windows ls is Get-ChildItem and returns file objects. On Linux and macOS PowerShell leaves the name alone, so ls is /bin/ls and returns lines of text. A string has a Length too – the number of its characters – so the filter still runs and silently answers a different question (Fig. 3).
ls, Length is the file size on Windows and the length of the file name elsewhere.ls | Where-Object Length -gt 50 # Windows: the large files. Linux, macOS: nothing
Get-ChildItem | Where-Object Length -gt 50 # the large files, everywhereSwitches, defaults and array parameters
A switch
An option that is either there or not – -Recurse, -Force, -WhatIf – is a parameter of type [switch]. It takes no value: it is $true when the caller writes it and $false when not, and PowerShell finds it wherever it stands on the command line. Your own scripts get one the same way. A typical use: a script that only shows its result by default, and changes the file when asked to.
param(
[string]$File,
[switch]$InPlace # no value: written = $true, left out = $false
)
# ... $codes: the cleaned lines of $File ...
if ($InPlace) {
Set-Content -Path $File -Value $codes -Encoding UTF8 # change the file itself
} else {
Write-Output $codes # only show the result
}./tidy_codes.ps1 codes.txt # $InPlace is $false: prints, the file is untouched
./tidy_codes.ps1 codes.txt -InPlace # $InPlace is $true: rewrites codes.txt
./tidy_codes.ps1 -InPlace codes.txt # a switch can stand anywhereWriting back into the file you have just read is safe here, as long as the lines are already in a variable when the writing starts. No temporary file is needed.
A default value – and "was it given at all?"
= "." after a parameter is its default. A typed parameter without a default is never empty: a missing [int] is 0, which is also a perfectly valid value – so if (-not $Days) can't tell "left out" from "zero". $PSBoundParameters holds the parameters the caller really passed; ask it.
param(
[int]$Days, # no default: 0 when it is left out
[string]$Directory = "." # default: the current directory
)
if (-not $PSBoundParameters.ContainsKey('Days')) { # "was it passed?" – not "is it non-zero?"
Write-Error "Usage: $($MyInvocation.MyCommand.Name) <days> [directory]"
exit 1
}An array parameter
Square brackets after the type make it an array: [string[]], [int[]]. The caller separates the elements with commas; a space starts the next parameter.
param(
[string]$Directory,
[string[]]$Extensions # an array: txt,csv
)./sort_by_extension.ps1 test_dir txt,csv # $Extensions has two elements
./sort_by_extension.ps1 test_dir txt csv # WRONG: only txt arrives; csv is a separate argument and is ignoredInside the script, $Extensions -contains 'txt' asks whether a value is in the array – no loop needed – and -notcontains is its negation. Like every comparison in PowerShell they ignore case.
Random values
Get-Random returns a random whole number below -Maximum. Together with the fact that a string can be indexed like an array, that is all a password generator needs: pick a random position in a string of allowed characters, as many times as the password is long.
Get-Random -Maximum 6 # a whole number from 0 to 5
Get-Random -Minimum 1 -Maximum 7 # 1 to 6: the maximum itself is never returned
$characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
$characters[0] # a – a string can be indexed like an array
$characters[(Get-Random -Maximum $characters.Length)] # one random characterHashtables: counting and summing per key
A hashtable stores one value per key, and the key can be any text – a name, a month, an extension. It is what other languages call a dictionary or a map, and what bash calls an associative array. One property makes it pleasant for counting: a key that isn't there yet reads as $null, and $null + 12 is 12, so the first addition needs no special case.
$pages = @{} # an empty hashtable
$pages['Toth Eszter'] = $pages['Toth Eszter'] + 12 # a missing key is $null, and $null + 12 = 12
$pages['Toth Eszter'] = $pages['Toth Eszter'] + 25 # 37
$pages['Toth Eszter'] # 37 – the value stored under a key
$pages.Count # 1 – the number of keys
$pages.Keys # every key, in no particular order
$pages.ContainsKey('Varga Dora') # False| Operation | Code |
|---|---|
| create | $pages = @{} |
| set a value | $pages['Varga Dora'] = 30 |
| add to a key | $pages[$name] = $pages[$name] + $n |
| read a value | $pages[$name] |
| all keys | $pages.Keys |
| number of keys | $pages.Count |
| is the key there? | $pages.ContainsKey($name) |
Sum per key, then the maximum
A print server keeps a log, print_log.txt: who printed, how many pages, on which printer. The same person shows up in several lines.
print_log.txt
Toth Eszter, 12, lab-2
Varga Dora, 30, library
Balogh Mate, 4, lab-1
Toth Eszter, 25, library
Szalai Anna, 18, lab-2
Varga Dora, 9, lab-1
Toth Eszter,8,lab-2Who printed the most pages? The answer takes two loops. The first reads the lines and adds each page count to the value stored under the name (Fig. 4):
"Toth Eszter" and "Toth Eszter " would be two people.foreach ($line in Get-Content -Path $File -Encoding UTF8) {
$line = $line.Trim()
if ($line -eq '') { continue }
$name, $count, $printer = $line -split ',' # three fields, three variables
$name = $name.Trim() # "Toth Eszter" – the space inside stays
$count = "$count".Trim() # " 12" → "12"
$pages[$name] = $pages[$name] + [int]$count # [int]: the field is still text
}The second loop walks the keys and remembers the largest value seen so far – the maximum search you have written in every language:
$maxKey = ""
$maxCount = 0
foreach ($key in $pages.Keys) {
if ($pages[$key] -gt $maxCount) {
$maxCount = $pages[$key]
$maxKey = $key
}
}
Write-Output "$maxKey - $maxCount" # Toth Eszter - 45The keys come out in no particular order – never rely on it. For a report, pass the keys through Sort-Object and loop over the result:
foreach ($name in ($pages.Keys | Sort-Object)) {
Write-Output "${name}: $($pages[$name])" # the braces keep the colon out of the variable name
}Balogh Mate: 4
Szalai Anna: 18
Toth Eszter: 45
Varga Dora: 39A composite key
"The most common illness per month" counts pairs: month and illness together. A hashtable has one key per entry, so glue the two fields into one string with a separator that can't occur in either – 10:fejfájás – and split it again when you read the keys back (Fig. 5).
$key = "${month}:$illness" # "10:fejfájás" – one string made of two fields
$counts[$key] = $counts[$key] + 1
$month, $illness = $key -split ':' # and apart again, when you read the keys backThe same with Group-Object
In a pipeline, Group-Object does the counting loop for you: it collects equal items and returns one object per group, with the properties Name (the key), Count and Group (the items themselves). If you know the Unix pipeline sort | uniq -c, this is its counterpart – except that the result isn't text you have to take apart again.
$words = 'apple', 'pear', 'apple', 'plum', 'pear', 'apple'
$words | Group-Object | Sort-Object -Property Count -Descending | Select-Object -Property Name, CountName Count
---- -----
apple 3
pear 2
plum 1A hashtable goes through a pipeline too, once you ask for its entries with GetEnumerator(). Each entry has a Key and a Value, so the maximum search becomes a sort:
$top = $pages.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 1
Write-Output "$($top.Key) - $($top.Value)" # Toth Eszter - 45Practice
Fourteen tasks in four groups, and a larger one to finish. Each has an expected result to check yourself against, and a hint with the approach and the lines that matter – the script is yours to write, so try before you open it.
- check the parameters, and that the input file or directory exists (
Test-Path) - error messages go through
Write-Error, and the script stops withexit 1 - read text files with
Get-Content -Encoding UTF8,.Trim()every line and skip the empty ones - full cmdlet names, no aliases
Files and directories
Task 1
A directory for every identifier
neptun_codes.txt holds one student identifier per line. For each of them create a directory of the same name – if it doesn't exist yet – inside users, and copy a welcome file into each one. The identifier file and the file to copy (read.me) are parameters; an optional third one replaces users. An identifier is valid if it is exactly six letters or digits; warn about the others and skip them. The file has CRLF line endings, an empty line, spaces around one code and no final newline.
PS> ./create_user_dirs.ps1 neptun_codes.txt read.me
Created: users/RVR55V
Created: users/ASD32B
Created: users/abc123
Created: users/GFGDDD
Created: users/AS55VF
WARNING: Invalid identifier skipped: 'TOOLONG1'
WARNING: Invalid identifier skipped: 'ab-12c'
Created: users/X1Y2Z3
6 directory(ies) created, 0 already existed, 2 invalid identifier(s)Run it a second time: every directory is now reported as Already exists, and the file is copied again anyway. On Windows the paths print as users\RVR55V.
Approach
Trim, skip empty lines, validate with an anchored pattern and {6}. Then the create-if-missing lines from the section on directories. Three counters give the summary line.
if ($id -notmatch '^[A-Za-z0-9]{6}$') { # exactly six letters or digits
Write-Warning "Invalid identifier skipped: '$id'"
$invalid++
continue
}
$userDir = Join-Path -Path $TargetDir -ChildPath $id
# ... create $userDir only if Test-Path -PathType Container says it is missing ...
Copy-Item -Path $ReadmeFile -Destination $userDir # on every run: an older copy is overwrittenTask 2
Random passwords
For every valid identifier in neptun_codes.txt, generate a random password and write identifier password lines to an output file. The length is an optional third parameter, 12 by default. Your passwords will differ from these – and from run to run.
PS> ./generate_passwords.ps1 neptun_codes.txt passwords.txt
WARNING: Invalid identifier skipped: 'TOOLONG1'
WARNING: Invalid identifier skipped: 'ab-12c'
6 password(s) of length 12 written to 'passwords.txt'
PS> Get-Content passwords.txt
RVR55V 2i064gPPF1B8
ASD32B ZkZhOQfckbbL
abc123 svDqCYsu7jkt
GFGDDD rfKwS1otaZ9Z
AS55VF nZXvNNIJPoDg
X1Y2Z3 28G19NiET0HiApproach
A function builds one password; the main loop is the validation of task 1 plus one call. Collect the output lines in an array and write them with a single Set-Content at the end.
function New-Password {
param([int]$Length)
$characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
$password = ""
for ($i = 0; $i -lt $Length; $i++) {
$password += $characters[(Get-Random -Maximum $characters.Length)]
}
return $password
}A pipeline can do it in one line: Get-Random -Count picks that many of the items it receives, and -join glues them together. Each character can then occur at most once.
-join ($characters.ToCharArray() | Get-Random -Count $Length)Task 3
Sort files into directories by extension
The script takes a directory and a list of extensions. For every listed extension it creates a subdirectory of that name and moves the matching files into it. Everything else stays where it is. Work on a copy of mixed_files.
PS> Copy-Item mixed_files test_dir -Recurse
PS> ./sort_by_extension.ps1 test_dir txt,csv
Moved: archive.TXT -> txt/
Moved: data.csv -> csv/
Moved: notes.txt -> txt/
Moved: prices.csv -> csv/
Moved: todo.txt -> txt/
5 file(s) movedarchive.TXT goes to txt despite its capitals; app.log, big.log and README, which has no extension, stay.
Approach
Loop over Get-ChildItem -File. Normalise the extension – drop the dot, lowercase – and test it against the array. Move by FullName, so the script works from any directory.
$extension = $file.Extension.TrimStart('.').ToLower() # ".TXT" → "txt", README → ""
if ($Extensions -notcontains $extension) {
continue # not asked for, or no extension: the file stays
}
$targetDir = Join-Path -Path $Directory -ChildPath $extension
# ... create $targetDir if it is missing ...
Move-Item -Path $file.FullName -Destination $targetDirTask 4
Files modified in the last n days
The script takes a number of days and, optionally, a directory (default: the current one), and lists the files modified within that many days, with the time of the modification. Files you have just downloaded are all new, so first make one of them older by assigning to its LastWriteTime:
PS> (Get-Item mixed_files/todo.txt).LastWriteTime = (Get-Date).AddDays(-10)
PS> ./modified_within_days.ps1 7 mixed_files
2026-11-11 14:45 app.log
2026-11-11 14:45 archive.TXT
2026-11-11 14:45 big.log
2026-11-11 14:45 data.csv
2026-11-11 14:45 notes.txt
2026-11-11 14:45 prices.csv
2026-11-11 14:45 README
7 file(s) modified in the last 7 day(s)Your dates will be today's; the point is that todo.txt is missing from the list.
Approach
Compute the limit once, before the loop. The check for a missing first parameter is the ContainsKey one – 0 days is a valid request.
$limit = (Get-Date).AddDays(-$Days)
foreach ($file in Get-ChildItem -Path $Directory -File) {
if ($file.LastWriteTime -ge $limit) {
$modified = $file.LastWriteTime.ToString('yyyy-MM-dd HH:mm')
Write-Output "$modified $($file.Name)"
}
}Task 5
Files larger than a given size
The script takes a size in bytes and, optionally, a directory, and lists the files larger than that, largest first, then how many there were.
PS> ./larger_than.ps1 50 mixed_files
3360 bytes big.log
84 bytes app.log
60 bytes notes.txt
3 file(s) larger than 50 bytes
PS> ./larger_than.ps1 1KB mixed_files
3360 bytes big.log
1 file(s) larger than 1024 bytesApproach
The core is the pipeline of Fig. 2, stored in a variable with @( ) so .Count is right for one file and for none. Because the parameter is a number type, the caller can write 1KB.
param(
[long]$MinSize, # [long]: a 64-bit integer; 1KB on the command line arrives as 1024
[string]$Directory = "."
)
$largeFiles = @(Get-ChildItem -Path $Directory -File |
Where-Object { $_.Length -gt $MinSize } |
Sort-Object -Property Length -Descending)Parameters and switches
Task 6
Tidy a list, optionally in place
The script takes a file of identifiers such as neptun_codes.txt and produces the tidy version of the list: only the valid codes, in uppercase, in alphabetical order. By default it prints the result and leaves the file alone; with the -InPlace switch it writes the result back into the file itself. Try the switch on a copy.
PS> ./tidy_codes.ps1 neptun_codes.txt
WARNING: Invalid identifier skipped: 'TOOLONG1'
WARNING: Invalid identifier skipped: 'ab-12c'
ABC123
AS55VF
ASD32B
GFGDDD
RVR55V
X1Y2Z3
PS> Copy-Item neptun_codes.txt test.txt
PS> ./tidy_codes.ps1 test.txt -InPlace
WARNING: Invalid identifier skipped: 'TOOLONG1'
WARNING: Invalid identifier skipped: 'ab-12c'
6 identifier(s) written back to 'test.txt'Approach
Validate as in task 1, collect the uppercased codes in an array, sort it, and let the switch decide where it goes. The switch must work in any position: ./tidy_codes.ps1 -InPlace test.txt.
$codes += $code.ToUpper() # inside the loop, after the validation
…
$codes = @($codes | Sort-Object) # @( ): still an array when a single code is left
if ($InPlace) {
Set-Content -Path $File -Value $codes -Encoding UTF8
Write-Output "$($codes.Count) identifier(s) written back to '$File'"
} else {
Write-Output $codes
}As a pipeline
Reading and writing the same file in one pipeline works only if the reading is finished before the writing starts – that is what the parentheses around Get-Content are for. Without them the file is opened for writing while it is still being read.
(Get-Content $File) | # the ( ) are required: read everything first
ForEach-Object { $_.Trim().ToUpper() } |
Where-Object { $_ -match '^[A-Z0-9]{6}$' } |
Sort-Object |
Set-Content $FileTask 7
A times table with a default
The script prints the times table of the number it gets, up to 10 – or up to the value of an optional second parameter, -UpTo. Without a number it prints a usage message. Make sure ./times_table.ps1 0 works: zero is a number too.
PS> ./times_table.ps1 7 -UpTo 3
7 x 1 = 7
7 x 2 = 14
7 x 3 = 21
PS> ./times_table.ps1
Write-Error: Usage: times_table.ps1 <number> [up to]Approach
Two [int] parameters, the second with a default. if (-not $Number) would reject the zero – ask $PSBoundParameters instead.
param(
[int]$Number,
[int]$UpTo = 10
)
if (-not $PSBoundParameters.ContainsKey('Number')) { … } # 0 is a number too: ./times_table.ps1 0 must work
foreach ($i in 1..$UpTo) {
Write-Output "$Number x $i = $($Number * $i)"
}Hashtables and grouping
Task 8
Who printed the most?
Lines of print_log.txt are Name, pages, printer. A name can appear in several lines, and its pages add up. Print who printed the most, and how many pages. The file has CRLF endings, an empty line, and its last line has neither spaces after the commas nor a newline.
PS> ./most_pages.ps1 print_log.txt
Toth Eszter - 45Approach
The two loops of the hashtable section: sum per name (Fig. 4), then the maximum search. .Trim() removes the spaces around a field and keeps the one inside the name. The count is still text after the split: convert it, or + glues strings – " 12" + 1 is " 121".
Going further
Change the key and the same script answers another question – which printer is the busiest? Expected: library - 55.
$printer = "$printer".Trim()
$pages[$printer] = $pages[$printer] + [int]$count # library: 55, lab-2: 38, lab-1: 13Task 9
The most common illness per month
Each line of patients_messy.txt is Name, YYYY-MM-DD, illness, and the file is dirty in every way: a header line, spaces around the commas, punctuation after the illness (fejfájás., fejfájás!, megfázás,), mixed case (Fejfájás), an empty and a broken line, CRLF endings, no final newline. For every month print the most common illness and its number of patients; warn about invalid lines. (The illnesses are Hungarian: fejfájás headache, megfázás cold, lábfájás leg pain, gyomorpanaszok stomach complaints.)
PS> ./most_common_illness_per_month.ps1 patients_messy.txt
WARNING: Invalid line skipped: 'Név, Dátum, Betegség'
WARNING: Invalid line skipped: 'Hibás Sor'
Month 05: lábfájás (3 patient(s))
Month 09: gyomorpanaszok (2 patient(s))
Month 10: fejfájás (3 patient(s))
Month 11: megfázás (3 patient(s))Step 1: clean and count
Lowercase the illness with .ToLower() – it handles accented letters – and delete everything that isn't a letter: \p{L} means "any letter", [^…] negates it. The header and the broken line fail the date check, so they need no special case. Count under a composite key.
$name, $date, $illness = $line -split ','
$date = "$date".Trim() # "$date": a missing field is $null, and $null has no Trim()
$illness = "$illness".ToLower() -replace '[^\p{L}]', '' # " Fejfájás." → "fejfájás"
if ($date -notmatch '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' -or $illness -eq '') {
Write-Warning "Invalid line skipped: '$line'"
continue
}
$month = $date.Substring(5, 2) # characters 5 and 6 of 2023-10-13: "10"
$key = "${month}:$illness"
$counts[$key] = $counts[$key] + 1Steps 2 and 3: the maximum per month, sorted
Two more hashtables, both keyed by the month (Fig. 5). You only read $counts while looping over it, so no copy of the keys is needed.
foreach ($key in $counts.Keys) {
$month, $illness = $key -split ':'
if ($counts[$key] -gt $maxCount[$month]) { # a month not seen yet is $null – any count beats it
$maxCount[$month] = $counts[$key]
$maxIllness[$month] = $illness
}
}
foreach ($month in ($maxCount.Keys | Sort-Object)) {
Write-Output "Month ${month}: $($maxIllness[$month]) ($($maxCount[$month]) patient(s))"
}Pipeline version
Turn every cleaned line into an object of your own with a Month and an Illness property, and let Group-Object count – once by month, then by illness within each group. This is where objects pay off.
$records = foreach ($line in Get-Content -Path $File -Encoding UTF8) {
# ... clean and validate as before ...
[pscustomobject]@{ Month = $month; Illness = $illness }
}
$records | Group-Object -Property Month | Sort-Object -Property Name | ForEach-Object {
$top = $_.Group | Group-Object -Property Illness |
Sort-Object -Property Count -Descending | Select-Object -First 1
Write-Output "Month $($_.Name): $($top.Name) ($($top.Count) patient(s))"
}The same data is cleaned and counted in bash in the lesson on text processing – compare how much of that script was cleaning.
Task 10
Files and bytes per extension
For a directory given as a parameter, print how many files each extension has and how many bytes they take together, ignoring the case of the extension. Files without an extension are reported as (none).
PS> ./per_extension.ps1 mixed_files
.csv: 2 file(s), 79 bytes
.log: 2 file(s), 3444 bytes
.txt: 3 file(s), 105 bytes
(none): 1 file(s), 28 bytesApproach
Two hashtables with the same key – one counts, one adds up Length – and a sorted loop over the keys of either.
$extension = $file.Extension.ToLower() # ".TXT" and ".txt" are one key
if ($extension -eq '') { $extension = '(none)' }
$count[$extension] = $count[$extension] + 1
$bytes[$extension] = $bytes[$extension] + $file.LengthPipeline version
Group-Object accepts a script block as the key. Each group still carries its file objects in Group, so they can be measured.
Get-ChildItem -Path $Directory -File | Group-Object { $_.Extension.ToLower() } | ForEach-Object {
$sum = ($_.Group | Measure-Object -Property Length -Sum).Sum # $_.Group: the files of this group
Write-Output "$($_.Name): $($_.Count) file(s), $sum bytes"
}Task 11
Patients per month
From patients_messy.txt, print how many patients each month had, sorted by month. Expected: 05: 4, 09: 3, 10: 5, 11: 4.
Approach
One hashtable with the month as the key. The illness doesn't matter here, so only the date needs cleaning and checking – a simpler relative of task 9.
Strings and numbers are objects too
Task 12
Statistics of a number file
numbers_spaces.txt has one number per line – with spaces, a tab, empty lines, mixed line endings and two invalid lines. Print how many valid integers it has, the smallest, the largest and their average rounded to two decimals, and write the valid numbers in ascending order to sorted_numbers.txt. Warn about invalid lines with their line number.
PS> ./number_stats.ps1 numbers_spaces.txt
WARNING: Line 6: 'abc' is not an integer, skipped
WARNING: Line 8: '1.5' is not an integer, skipped
Count: 6
Smallest: -4
Largest: 100
Average: 22.17
PS> Get-Content sorted_numbers.txt
-4
3
7
12
15
100Approach
Trim, skip empty lines, validate with '^-?[0-9]+$', and collect the values in an array as numbers. Measure-Object then does all the arithmetic in one call.
$numbers += [int]$line # collect numbers, not strings: as text, "100" sorts before "9"
…
$stats = $numbers | Measure-Object -Minimum -Maximum -Average
$stats.Count # 6
[math]::Round($stats.Average, 2) # 22.17
Set-Content -Path sorted_numbers.txt -Value ($numbers | Sort-Object) -Encoding UTF8Task 13
The longest line
Print the longest line of read.me and its length. Solve it with a loop (a maximum search) and with a pipeline. Expected: 47 characters, Please read the rules before you start working:.
Approach
A line is a string, and a string has a Length just as a file does. So the pipeline is the one that found the largest file:
Get-Content -Path $File -Encoding UTF8 |
Sort-Object -Property Length -Descending |
Select-Object -First 1Task 14
Slow requests in a log
Lines of mixed_files/big.log look like … request 051 processed in 97 ms. Print how many requests took longer than 80 ms, and which one was the slowest. Expected: 11 requests; the slowest is 051 with 97 ms.
Approach
After a successful -match, the automatic variable $Matches holds what each ( ) group of the pattern matched. The groups are text; convert before comparing. Then a counter and a maximum search.
if ($line -match 'request ([0-9]+) processed in ([0-9]+) ms') {
$request = $Matches[1] # the first ( ) group: 051
$ms = [int]$Matches[2] # the second one, as a number: 97
}A larger one
Task 15
Port a bash script
Take a bash script you have written – for example the grades report from the lesson on reading and writing files, or the patient queries with options from shell scripting in practice – and write it in PowerShell.
Approach
Don't translate line by line; decide first what the script does with its input. Options become [switch] or named parameters, case becomes switch, parallel arrays or declare -A become a hashtable – and count the lines you no longer need: the guard for a last line without a newline, the \r stripping, the temporary file.
Common mistakes
| Wrong | Right | Why |
|---|---|---|
ls | Where-Object { $_.Length -gt 1KB } | Get-ChildItem | Where-Object { $_.Length -gt 1KB } | On Linux and macOS ls returns text; Length is the length of the name. |
"Size: $file.Length" | "Size: $($file.Length)" | A property inside a string needs $( ). |
$file.Extension -eq 'txt' | $file.Extension -eq '.txt' | The extension includes the dot. |
Get-ChildItem $dir for "the files" | Get-ChildItem $dir -File | Without -File the subdirectories come too. |
"$TargetDir\$id" | Join-Path -Path $TargetDir -ChildPath $id | Outside Windows, programs don't treat \ as a separator. |
New-Item … printing the new directory | New-Item … | Out-Null | Whatever a command returns is part of the script's output. |
Remove-Item $Directory -Recurse, untested | first with -WhatIf | It only prints what it would delete; the real delete is permanent. |
./script.ps1 test_dir txt csv | ./script.ps1 test_dir txt,csv | Array elements are separated by commas; a space starts another argument. |
if ($Days) for "was it given?" | $PSBoundParameters.ContainsKey('Days') | 0 is false, but it can be a valid value. |
a parameter or key named Count | another name ($Times, $Total) | In a hashtable – and in $PSBoundParameters – the key Count hides the .Count property. |
$count + 1 after -split | [int]$count + 1 | The field is text: " 12" + 1 is " 121". |
| sorting numbers that are still strings | convert first: [int]$line | As text, 100 sorts before 9. |
$date.Trim() on a short line | "$date".Trim() | A missing field is $null, and $null has no methods. |
foreach ($key in $counts.Keys) { $counts[$key] = 0 } | foreach ($key in @($counts.Keys)) { … } | A hashtable can't change while it is being walked; @( ) copies the keys. |
printing $counts.Keys as they come | $counts.Keys | Sort-Object | The order of the keys is not defined. |
Get-Content $f | … | Set-Content $f | (Get-Content $f) | … | Set-Content $f | Without the parentheses the file is written while it is still being read. |
… > out.txt in a script | … | Set-Content -Path out.txt -Encoding UTF8 | In Windows PowerShell 5.1, > writes UTF-16. |
Summary
- file object
- what
Get-ChildItemandGet-Itemreturn:Name,Extension,Length,LastWriteTime,FullName - date object
- a point in time with methods (
AddDays,ToString); compares with-ge,-lt - switch
- a
[switch]parameter: no value, true when present - hashtable
@{}– one value per key; a missing key reads as$null- composite key
- two fields glued into one key with a separator:
10:fejfájás - Group-Object
- groups equal items and counts them:
Name,Count,Group
- Ask the object:
$file.Length,$file.Extension,$file.LastWriteTime– and alwaysGet-ChildItem -Filewhen you mean files. - Dates compare like numbers;
(Get-Date).AddDays(-7)is "a week ago". Sizes takeKB,MB,GB. - Create only what is missing (
Test-Path), build paths withJoin-Path, silenceNew-ItemwithOut-Null, rehearse deletes with-WhatIf. ls,cat,cp,mv,rmandsortare aliases only on Windows – full cmdlet names in scripts.- Options are
[switch]parameters, optional values have defaults, and$PSBoundParameters.ContainsKey()tells you what was really passed. - Per-key questions: clean the key,
$h[$k] = $h[$k] + n, then a maximum search or a sorted loop over the keys. - Never assign to a hashtable inside a loop over its own keys – loop over
@($h.Keys).
Further reading
- about_Hash_Tables – everything a hashtable can do, including ordered ones (
[ordered]@{}). - about_Functions_Advanced_Parameters – parameter types, defaults, switches and validation attributes.
- about_Regular_Expressions – the regex dialect of
-matchand-replace,\p{L}included. - Get-ChildItem – all its parameters; the same page from the terminal:
Get-Help Get-ChildItem -Examples. Get-ChildItem | Get-Memberlists every property and method of a file object, right in the terminal.- regex101 – try a pattern and have each piece explained; choose the ".NET (C#)" flavour for PowerShell.