R

ReflexKR

Reflex Docs

Regular expressions you can see

·

Course overview

1

The missing payment: finding text as it is

2

The hit-and-run car: character classes

3

The torn phone book: quantifiers

4

The fake sign-ups: anchors and validation

5

Dates all over the place: groups and alternation

6

The 3 a.m. login: named groups and Match objects

7

The night before the story: substitution and splitting

8

The Backtracker's challenge: greedy, lazy and possessive

9

The safe's rules: lookaround

10

Same modus operandi: backreferences and conditionals

11

The unreadable regex: flags and compiling

12

Unmasking the Backtracker: review and a tokenizer

Regular expressions you can see
Lesson 6

Time 4:10–5:00

The 3 a.m. login: named groups and Match objects

CASE-006

Grep & Co. Data Detective Agency

Case file

The 3 a.m. login

Client

Ms. Jeong, who runs the online store "Market Jeong"

Evidence

The access log of Market Jeong's web server: the record of that same small-hours night when the fake sign-ups poured in.

To close the case

Split a log line into named fields (a dict), and find the IP that got into the admin page (/admin) at 3 a.m.

Ms. Jeong is back again, and this time she's brought the whole laptop. "The fake accounts came in the middle of the night, right? Apparently someone opened the admin screen at that hour. I was asleep then."

The director skims the screen once. "Web server log. About seven fields per line. Call them by number and you'll get lost. Give them names."

203.0.113.7 - - [20/Oct/2026:02:58:40 +0900] "GET /products/42 HTTP/1.1" 200 5123
198.51.100.23 - - [20/Oct/2026:03:01:05 +0900] "POST /login HTTP/1.1" 302 0
203.0.113.66 - - [20/Oct/2026:03:02:11 +0900] "GET /admin HTTP/1.1" 200 8890
203.0.113.66 - - [20/Oct/2026:03:02:19 +0900] "GET /admin/users?page=2 HTTP/1.1" 200 12044
198.51.100.4 - - [20/Oct/2026:09:15:30 +0900] "GET /admin HTTP/1.1" 200 8890
203.0.113.7 - - [20/Oct/2026:09:16:02 +0900] "GET /cart HTTP/1.1" 200 2210

From the left, each line holds the visitor's IP, two empty fields (-), the time in square brackets, the request in quotes (method, path, protocol), the response code, and the number of bytes sent.

6-1. Name tags instead of numbers: (?P...) (10 min)

In Lesson 5 you called groups by number, as in group(1) and group(2). Once there are more than seven groups, there's no way to remember which number is what. To give a group a name, write ?P<name> right after the opening parenthesis.

import re

line = '203.0.113.66 - - [20/Oct/2026:03:02:11 +0900] "GET /admin HTTP/1.1" 200 8890'
m = re.search(r"(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\]", line)

print(m.group("ip"))          # 203.0.113.66
print(m["time"])              # 20/Oct/2026:03:02:11 +0900
print(m.group("ip", "time"))  # ('203.0.113.66', '20/Oct/2026:03:02:11 +0900')
print(m.groupdict())          # {'ip': '203.0.113.66', 'time': '20/Oct/2026:03:02:11 +0900'}
  • m.group("ip") and m["ip"] are the same. Pass several names and you get a tuple back.
  • groupdict() collects only the named groups and returns them as a {name: text} dictionary. One log line becomes one dict.
  • Names follow the rules for Python identifiers. Starting with a digit or containing a - is an error.

Named groups still have their numbers. The numbering works exactly as in Lesson 5, in the order of the opening parentheses. A name is just a nickname stuck on top of the number.

print(m.group(1))   # 203.0.113.66
print(m[2])         # 20/Oct/2026:03:02:11 +0900
print(m.groups())   # ('203.0.113.66', '20/Oct/2026:03:02:11 +0900')

Evidence lab

re.search(r"", text)

Return value

Switch to finditer and look at m.group("ip") and groupdict() in the first-match panel. If you change (?P<time>...) to a plain (...), what disappears from the dict?

Dot's tip

If you've seen (?<name>...) in other languages, watch out. In Python, leave out the P and you get an "unknown extension ?<n" error. And give the same name twice and it's a "redefinition of group name" error. I only have one name too, you know.

6-2. A whole log line as a dict (10 min)

Now give every field in the line a name. The spaces, quotes and square brackets between fields go into the pattern as literal characters.

LINE_RE = r'(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] "(?P<method>[A-Z]+) (?P<path>\S+) [^"]*" (?P<status>\d{3}) (?P<size>\d+)'

m = re.fullmatch(LINE_RE, line)
print(m.groupdict())
# {'ip': '203.0.113.66', 'time': '20/Oct/2026:03:02:11 +0900', 'method': 'GET', 'path': '/admin', 'status': '200', 'size': '8890'}
  • The pattern contains double quotes, so the raw string is wrapped in single quotes, r'...'.
  • [^\]]+ is "characters that aren't a closing square bracket", and [^"]* is "characters that aren't a quote". Lesson 2's negated classes mark where each field ends.
  • The whole line has to have this shape, so we use fullmatch from Lesson 4. A line with a broken shape becomes None and stands out right away.

Once it's a dict, the rest is ordinary Python. Say the six log lines above are stored in a string called access_log.

rows = [re.fullmatch(LINE_RE, l).groupdict() for l in access_log.splitlines()]
print(len(rows))                                            # 6
print(rows[1]["method"], rows[1]["path"], rows[1]["status"])  # POST /login 302

All the values are strings. To add up the byte counts, you have to convert them yourself, as in int(row["size"]).

Evidence lab

re.search(r"", text)

Return value

The pattern stops at the request path. Extend it with the protocol, the response code (status) and the byte count (size) so the whole line fullmatches. The last line, which is cut off at the end, must not pass.

6-3. Where was it? start, end, span (6 min)

Besides what it found, a Match object also tells you where it found it, for each group. Pass a group number or name as the argument and you get that group's position.

m = re.fullmatch(LINE_RE, line)
print(m.start("path"), m.end("path"))   # 51 57
print(m.span("status"))                 # (68, 71)
print(line[m.start("path"):m.end("path")])  # /admin

An optional group might not match at all. A query string such as the ?page=2 after a path is one example. A group that didn't match has None for its text and -1 for its position.

P = r"(?P<path>/[^\s?]*)(?:\?(?P<query>\S+))?"
a = re.search(P, "GET /admin HTTP/1.1")
b = re.search(P, "GET /admin/users?page=2 HTTP/1.1")

print(a.group("query"), a.span("query"))   # None (-1, -1)
print(b.group("query"), b.span("query"))   # page=2 (17, 23)

A stray None easily causes errors in string handling. groups() and groupdict() take a default argument: the value to put in place of None.

print(a.groups())                  # ('/admin', None)
print(a.groups(default=""))        # ('/admin', '')
print(a.groupdict(default="-"))    # {'path': '/admin', 'query': '-'}

Evidence lab

re.search(r"", text)

Return value

finditer splits each line into its path and query string. Check in the first-match panel that query is None, then delete the first two lines of the text and see whether page=2 gets caught. Delete the leading [A-Z]+ and even the /1.1 of HTTP/1.1 gets caught as a path.

6-4. The rest of the Match object's attributes (6 min)

You won't use these often, but they come in handy when debugging.

m = re.search(r"(?P<method>[A-Z]+) (?P<path>\S+)", line)

print(m.lastindex, m.lastgroup)   # 2 path
print(m.pos, m.endpos, len(line)) # 0 76 76
print(m.string is line)           # True
print(m.re)                       # re.compile('(?P<method>[A-Z]+) (?P<path>\\S+)')
  • lastindex is the number of the last group to close, and lastgroup is that group's name. If it has no name, or no group matched, you get None.
  • With nested groups, the outer parenthesis closes last. That's why the lastindex of ((\d+)\.(\d+)) is 1, not 3.
  • When named groups are joined with |, as in (?P<a>x)|(?P<b>y), lastgroup tells you which side matched. In Lesson 12 you'll build a tokenizer on exactly this property.
  • pos and endpos are the search range. With the module functions they're always 0 and the length of the string. You'll learn to set the range yourself with re.compile in Lesson 11.
  • string is the original string that was searched, and re is the pattern object that was used.
n = re.search(r"((\d+)\.(\d+))", "v3.14")
print(n.lastindex, n.lastgroup)   # 1 None
n = re.search(r"(?P<a>x)|(?P<b>y)", "y")
print(n.lastindex, n.lastgroup)   # 2 b

A Match object is an immutable value, so copy.copy(m) and copy.deepcopy(m) are fine. What you get back is the very same object.

Finally, expand(template) slots the match into a template to build a new string. Every \g<name> in the template is replaced by that group's text. It's exactly the same syntax as the replacement strings in Lesson 7. (요청 below means "request".)

print(m.expand(r"\g<method> 요청: \g<path>"))   # GET 요청: /admin

6-5. The admin page at 3 a.m. (8 min)

Now put the case's condition into the pattern. In the time field [20/Oct/2026:03:02:11 +0900], the two digits after the first : following the date are the hour. Skip the date with \[[^:]+:, write 03 in that spot, and you have the condition "3 a.m.".

  • Search for just 03 or :03: and you'll also catch lines where the minutes or seconds are 03. What matters is pinning down the position: which field's 03 you mean.
  • Write the path as /admin\S* so it catches every page that starts with /admin.

Evidence lab

re.search(r"", text)

Return value

Right now the pattern even catches Ms. Jeong's own login at 9 in the morning. Keep only the /admin visits where the hour in the time field is 03. Even better, capture ip and hour together with named groups.

Here's one possible answer.

(?P<ip>\S+) \S+ \S+ \[[^:]+:(?P<hour>03):[^\]]+\] "GET (?P<path>/admin\S*)

Cracking the case (10 min)

Split the time into day, month, year, hour, minute and second, name every piece, then pull each line out like a dict and test the conditions. Thanks to the named groups, the if reads almost like plain English.

TIME_RE = (r'(?P<ip>\S+) \S+ \S+ '
           r'\[(?P<day>\d\d)/(?P<mon>\w{3})/(?P<year>\d{4}):(?P<hour>\d\d):(?P<min>\d\d):(?P<sec>\d\d) [^\]]+\] '
           r'"(?P<method>[A-Z]+) (?P<path>\S+)')

suspects = set()
for l in access_log.splitlines():
    m = re.match(TIME_RE, l)
    if m and m["hour"] == "03" and m["path"].startswith("/admin"):
        print(m["hour"] + ":" + m["min"], m["ip"], m["path"])
        suspects.add(m["ip"])
# 03:02 203.0.113.66 /admin
# 03:02 203.0.113.66 /admin/users?page=2

print(suspects)   # {'203.0.113.66'}

At 3:02 a.m., 203.0.113.66 opened the admin screen, and eight seconds later it had pulled down page 2 of the member list. The 198.51.100.4 at 9 a.m. was the IP of Ms. Jeong's own office. Ms. Jeong goes pale. "The member list? Then our customers' data…"

Case closed report — On October 20 at 03:02, the external IP 203.0.113.66 logged into the admin page and viewed the member list. Recommended changing the admin password immediately and restricting access by IP. Member data may have been leaked.

Exercises

  1. Read the log with LINE_RE and add up the size of every line whose response code (status) is 200. Don't forget int().
  2. Find "2026-10-20" with the named groups (?P<y>\d{4})-(?P<m>\d\d)-(?P<d>\d\d), then predict the result of m.expand(r"\g<d>/\g<m>/\g<y>").
  3. For re.search(r"(?P<a>\d)(?P<b>x)?", "7"), predict span("b"), groupdict(default="?") and lastgroup, then check.

The director's notebook

  • (?P<name>...) gives a group a name. m.group("name"), m["name"], m.groupdict().

  • Named groups still have numbers, in the order of the opening parentheses.

  • start(g), end(g), span(g). A group that didn't match is None, with position -1.

  • groups(default=...) and groupdict(default=...) put another value in place of None.

  • lastindex is the number of the last group to close; lastgroup is its name. It tells you which of several |-joined named groups matched.

  • m.pos, m.endpos, m.string, m.re. m.expand(r"\g<name>") slots the match into a template.

← Previous

Lesson 5 · Dates all over the place: groups and alternation

Next →

Lesson 7 · The night before the story: substitution and splitting

Get news about new lessons

We'll email you when this course gets new lessons or a follow-up course opens.

We collect: your email address · Purpose: news about new lessons or the launch of this course · Kept until: you ask us to remove it. We never use it for anything else or share it. Reply to any notice email and we'll delete it right away.

Questions & answers

Ask anything about this lesson. Questions from every language version of this page appear together.

Shown as plain text, without links or formatting.

R

ReflexKR

Learn Python and Reflex by doing. Every example was run and verified on Reflex 0.10.0.

ReflexKR is free, with no banner ads. If it helped you, you can support it with a coffee. ☕ Buy us a coffee

Affiliate disclosure
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex