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.

20 min readFile objects: Length, Extension, LastWriteTimeNew-Item, Join-Path, Copy-Item, -WhatIfAliases that exist only on Windows[switch], defaults and array parametersHashtables and composite keysGroup-Object

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.

bash – ls -l prints a line of text-rw-r--r--1annastaff60Nov 11 14:45notes.txtawk '{ print $5 }'column 5count the columns yourselfPowerShell – Get-ChildItem returns an objectone file object: its data has namesNamenotes.txtExtension.txtLength60LastWriteTimea date objectFullName/…/notes.txt
Fig. 1 · The same file, twice: as a line of text whose fifth column happens to be the size, and as an object with a 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
PropertyWhat it holds
Namethe file name: notes.txt
BaseNamethe name without the extension: notes
Extensionthe extension with the dot: .txt; an empty string if there is none
FullNamethe absolute path
Lengththe size in bytes, as a number
LastWriteTimewhen 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, Length
Name      Length
----      ------
big.log     3360
app.log       84
notes.txt     60
Get-ChildItem -File8 file objectsapp.log84archive.TXT17big.log3360data.csv44notes.txt60prices.csv35README28todo.txt28|Where-Object { $_.Length -gt 50 }3 pass the filterapp.log84big.log3360notes.txt60|Sort-Object Length -Descendinglargest firstbig.log3360app.log84notes.txt60
Fig. 2 · Eight file objects go in; the filter and the sort both use Length. 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: 3656

When 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
QuestionPipeline
files larger than 1 KBGet-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 filesGet-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 …CmdletNote
create a directoryNew-Item -Path users -ItemType Directoryan error if it already exists
create it with its parentsNew-Item -Path users/a/b -ItemType Directory -Forceno error if it exists
test for a directoryTest-Path -Path $dir -PathType Containerfor a file: -PathType Leaf
copy a fileCopy-Item -Path read.me -Destination usersoverwrites a file of the same name
copy a directoryCopy-Item -Path src -Destination dst -Recursewith everything in it
move or renameMove-Item -Path notes.txt -Destination txt
deleteRemove-Item -Path test_dir -Recursepermanent – 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.

AliasesWhere they exist
gc, dir, gci, echo, cd, copy, move, del, mdeverywhere
?, where, %, foreach, select, measure, group, slseverywhere
ls, cat, cp, mv, rm, sortonly 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 | Where-Object Length -gt 50Windowslsalias of Get-ChildItemfile objectsLength = size in bytes3 fileslarger than 50 bytesLinux, macOSlsthe real /bin/lslines of textLength = length of the nameno outputno name is that longGet-ChildItem – and its aliases dir and gci – returns file objects on every system.
Fig. 3 · One command line, two meanings. With 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, everywhere

Switches, 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 anywhere

Writing 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 ignored

Inside 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 character

Hashtables: 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
OperationCode
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-2

Who 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):

print_log.txtToth Eszter, 12, lab-2Varga Dora, 30, libraryBalogh Mate, 4, lab-1Toth Eszter, 25, librarySzalai Anna, 18, lab-2Varga Dora, 9, lab-1Toth Eszter,8,lab-2$pages[$name] + [int]$counta missing key: $null + 12 = 12$pages – name → totalToth Eszter12 + 25 + 8 = 45Varga Dora30 + 9 = 39Balogh Mate4Szalai Anna18Trim() cuts only the two ends of a field: the space inside “Toth Eszter” stays, so all three lines land on one key.
Fig. 4 · Three lines with the same name feed one entry. The key must be cleaned first – "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 - 45

The 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: 39

A 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 back
1 · clean and count: $counts10:fejfájás310:megfázás209:gyomorpanaszok209:fejfájás111:megfázás311:fejfájás105:lábfájás305:fejfájás12 · the maximum per month10fejfájás309gyomorpanaszok211megfázás305lábfájás3$maxIllness and $maxCount,both keyed by the month –in no particular order3 · sort the keys, printMonth 05: lábfájás (3)Month 09: gyomorpanaszok (2)Month 10: fejfájás (3)Month 11: megfázás (3)$maxCount.Keys | Sort-Object
Fig. 5 · Three steps, three loops: count under month:illness, keep the best illness of every month, print the months in sorted order.

The 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, Count
Name  Count
----  -----
apple     3
pear      2
plum      1

A 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 - 45

Practice

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 with exit 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 overwritten

Task 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 28G19NiET0Hi
Approach

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) moved

archive.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 $targetDir

Task 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 bytes
Approach

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 $File

Task 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 - 45
Approach

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: 13

Task 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] + 1
Steps 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 bytes
Approach

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.Length
Pipeline 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
100
Approach

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 UTF8

Task 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 1

Task 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

WrongRightWhy
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 -FileWithout -File the subdirectories come too.
"$TargetDir\$id"Join-Path -Path $TargetDir -ChildPath $idOutside Windows, programs don't treat \ as a separator.
New-Item … printing the new directoryNew-Item … | Out-NullWhatever a command returns is part of the script's output.
Remove-Item $Directory -Recurse, untestedfirst with -WhatIfIt only prints what it would delete; the real delete is permanent.
./script.ps1 test_dir txt csv./script.ps1 test_dir txt,csvArray 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 Countanother name ($Times, $Total)In a hashtable – and in $PSBoundParameters – the key Count hides the .Count property.
$count + 1 after -split[int]$count + 1The field is text: " 12" + 1 is " 121".
sorting numbers that are still stringsconvert first: [int]$lineAs 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-ObjectThe order of the keys is not defined.
Get-Content $f | … | Set-Content $f(Get-Content $f) | … | Set-Content $fWithout the parentheses the file is written while it is still being read.
… > out.txt in a script… | Set-Content -Path out.txt -Encoding UTF8In Windows PowerShell 5.1, > writes UTF-16.

Summary

file object
what Get-ChildItem and Get-Item return: 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 always Get-ChildItem -File when you mean files.
  • Dates compare like numbers; (Get-Date).AddDays(-7) is "a week ago". Sizes take KB, MB, GB.
  • Create only what is missing (Test-Path), build paths with Join-Path, silence New-Item with Out-Null, rehearse deletes with -WhatIf.
  • ls, cat, cp, mv, rm and sort are 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 -match and -replace, \p{L} included.
  • Get-ChildItem – all its parameters; the same page from the terminal: Get-Help Get-ChildItem -Examples.
  • Get-ChildItem | Get-Member lists 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.