fluidattacks-agent 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,161 @@
1
+ """Pack a report into a batch the centre can order, verify and decompress."""
2
+
3
+ import os
4
+ import threading
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Final
8
+
9
+ from fluidattacks_agent.settings import Delivery, Secret
10
+
11
+ BATCH_TAG: Final = "watches-batch/1"
12
+
13
+ # what the centre keys on. The whitepaper's unit is a machine that outlives a
14
+ # reboot; ours is an interpreter that may live for seconds, so what names one is
15
+ # minted per start and never reused
16
+ INSTANCE_BYTES: Final = 16
17
+
18
+ # a probe's own health is not a workload's evidence and must not be filed as it
19
+ EXEC: Final = "exec"
20
+
21
+ # what orders one instance's batches. Wrapping would land on a key already
22
+ # ingested and be discarded as a duplicate, so the counter refuses instead
23
+ MAX_SEQ: Final = 1 << 53
24
+
25
+ # a flush runs inside a host callback, so this is the workload's own latency:
26
+ # measured on a mebibyte of real report text, level one costs 4.5 ms for 3.1x
27
+ # and level six costs 18.6 ms for 3.7x. Fourteen milliseconds of somebody
28
+ # else's request is not worth sixty kibibytes
29
+ COMPRESSION: Final = 1
30
+
31
+
32
+ def packed(text: str) -> bytes:
33
+ """Compress a report, the same way every time it holds the same thing."""
34
+ # here and not above: a flush is what needs this, and two milliseconds of
35
+ # interpreter start is not owed by a workload that never reaches one
36
+ import gzip # noqa: PLC0415
37
+
38
+ # mtime zero because a gzip header otherwise carries a clock, and a probe
39
+ # that asserts no phase of its own should not assert one here either
40
+ return gzip.compress(text.encode(), compresslevel=COMPRESSION, mtime=0)
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class Batch:
45
+ """One delivery: what names it, what orders it, and what proves it."""
46
+
47
+ instance: str
48
+ seq: int
49
+ stream: str
50
+ workload: str
51
+ group: str
52
+ body: bytes
53
+ signature: str
54
+
55
+
56
+ def minted() -> str:
57
+ """Name this interpreter, once, out of the kernel's own entropy."""
58
+ # not secrets.token_hex: the same source at the same 0.8 us a call, but
59
+ # importing it costs 3.5 ms of a start this package spent months shortening
60
+ return os.urandom(INSTANCE_BYTES).hex()
61
+
62
+
63
+ def _stated(*parts: str) -> bytes:
64
+ # length-prefixed over the encoded bytes, so that no two different batches
65
+ # can spell alike whatever the values hold: being unambiguous is a property
66
+ # of this form rather than a promise made somewhere else
67
+ return b"\n".join(b"%d:%s" % (len(spelt), spelt) for spelt in map(str.encode, parts))
68
+
69
+
70
+ def derived(token: Secret) -> bytes:
71
+ """Make a key that signs, and is not the credential that authorises."""
72
+ import hmac # noqa: PLC0415
73
+
74
+ # so a signature cannot be replayed as a credential, and a credential
75
+ # cannot be read back out of a signature
76
+ return hmac.digest(token.held.encode(), BATCH_TAG.encode(), "sha256")
77
+
78
+
79
+ def batched(
80
+ delivery: Delivery,
81
+ instance: str,
82
+ seq: int,
83
+ stream: str,
84
+ text: str,
85
+ ) -> Batch | None:
86
+ """Name a report, order it, pack it, and prove where it came from."""
87
+ if not 0 < seq < MAX_SEQ:
88
+ return None
89
+ import hmac # noqa: PLC0415
90
+
91
+ body = packed(text)
92
+ workload = delivery.workload or ""
93
+ group = delivery.group or ""
94
+ proof = hmac.new(derived(delivery.token), digestmod="sha256")
95
+ # what travels beside the body is signed with it, because a proxy that can
96
+ # rewrite which instance or which tenant sent this misfiles it without
97
+ # touching a byte of the evidence
98
+ proof.update(_stated(BATCH_TAG, group, workload, instance, str(seq), stream))
99
+ proof.update(body)
100
+ return Batch(
101
+ instance=instance,
102
+ seq=seq,
103
+ stream=stream,
104
+ workload=workload,
105
+ group=group,
106
+ body=body,
107
+ signature=proof.hexdigest(),
108
+ )
109
+
110
+
111
+ @dataclass
112
+ class Batcher:
113
+ """One instance's batches, in the order that instance made them."""
114
+
115
+ instance: str = ""
116
+ seq: int = 0
117
+ # two host callbacks can be inside a flush at the same time, because what
118
+ # keeps a second one out is a plain attribute and not a mutex: measured,
119
+ # eight threads asking for a place got the same one in all three hundred
120
+ # rounds, and two batches sharing a place have one of them discarded
121
+ guard: threading.Lock = field(default_factory=threading.Lock, repr=False)
122
+
123
+ def named(self) -> str:
124
+ """Say what names this interpreter, minting it the first time asked."""
125
+ if not self.instance:
126
+ self.instance = minted()
127
+ return self.instance
128
+
129
+ def next(
130
+ self,
131
+ delivery: Delivery,
132
+ text: str,
133
+ taken: Callable[[Batch], bool],
134
+ stream: str = EXEC,
135
+ ) -> Batch | None:
136
+ """Build the next batch, and spend its place only once it is held."""
137
+ # taking the place, building against it and offering it are one step, so
138
+ # that no two batches can be built against the same place and no place
139
+ # is spent on a batch that was turned away. Whoever takes it is asked
140
+ # from in here for that reason, and never asks anything back of this
141
+ with self.guard:
142
+ held = self.seq + 1
143
+ batch = batched(delivery, self.named(), held, stream, text)
144
+ if batch is None or not taken(batch):
145
+ return None
146
+ # a place spent on a batch nothing is holding would leave a hole,
147
+ # and a hole is how evidence lost in flight is meant to look
148
+ self.seq = held
149
+ return batch
150
+
151
+ def forked(self) -> None:
152
+ """Forget what named this instance, because a child of it is another."""
153
+ # a lock held by another thread at the moment of the fork is held in the
154
+ # child forever, and a fresh one cannot be: the child has no second
155
+ # thread, so there is nothing for this one to be held against
156
+ self.guard = threading.Lock()
157
+ # a child inherits the name and the count and goes on emitting the keys
158
+ # its parent is emitting: measured across a fork, every key after it
159
+ # collided, and ingest discards a collision without saying so
160
+ self.instance = ""
161
+ self.seq = 0
@@ -0,0 +1,158 @@
1
+ """Carry what a workload reported out of the process it was reported in."""
2
+
3
+ import contextlib
4
+ import errno
5
+ import os
6
+ import threading
7
+ import time
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass, field
10
+ from typing import Final
11
+
12
+ from fluidattacks_agent.batch import Batch, Batcher
13
+ from fluidattacks_agent.outbox import Outbox
14
+ from fluidattacks_agent.patience import TOPMOST, fraction, splay, standoff
15
+ from fluidattacks_agent.post import TIMEOUT, Verdict, posted
16
+ from fluidattacks_agent.settings import Delivery
17
+ from fluidattacks_agent.sink import Stalled
18
+
19
+ # how old the centre's picture may be while nothing new is being found
20
+ INTERVAL: Final = 15.0
21
+
22
+ # what a workload's own exit may be delayed by while what is held goes out
23
+ PARTING: Final = 2.0
24
+
25
+ NAME: Final = "fluidattacks-agent"
26
+
27
+
28
+ @dataclass
29
+ class Deliverer:
30
+ """A sink that puts a report on its way out rather than in a directory."""
31
+
32
+ told: Delivery
33
+ batcher: Batcher = field(default_factory=Batcher)
34
+ outbox: Outbox = field(default_factory=Outbox)
35
+ carry: Callable[[Delivery, Batch, float], Verdict] = posted
36
+ # kept in hand rather than put back, so its place stays its place
37
+ holding: Batch | None = field(default=None, repr=False)
38
+ interval: float = INTERVAL
39
+ share: Callable[[], float] = fraction
40
+ # a wake says there is something to carry, never that it is welcome
41
+ due: float = 0.0
42
+ tries: int = 0
43
+ woken: threading.Event = field(default_factory=threading.Event, repr=False)
44
+ thread: threading.Thread | None = field(default=None, repr=False)
45
+ # the thread field alone, and never held across anything that can block
46
+ guard: threading.Lock = field(default_factory=threading.Lock, repr=False)
47
+ # one pass at a time: what is taken but not delivered lives in one place
48
+ passing: threading.Lock = field(default_factory=threading.Lock, repr=False)
49
+ leaving: bool = False
50
+ delivered: int = 0
51
+
52
+ def write(self, text: str) -> None:
53
+ """Take a report for delivery, or refuse and be counted for it."""
54
+ if self.batcher.next(self.told, text, self.outbox.put) is None:
55
+ raise Stalled(errno.ENOSPC, "nothing taken", len(text))
56
+ self._rouse()
57
+
58
+ def _rouse(self) -> None:
59
+ """Say there is something to carry, and find a carrier if there is none."""
60
+ with self.guard:
61
+ # a carrier that stopped takes delivery with it, and one is not
62
+ # worth starting for a process already on its way out
63
+ if not self.leaving and (self.thread is None or not self.thread.is_alive()):
64
+ # replicas start in the same second, so this is drawn; and
65
+ # once, so that it can never overwrite a standoff
66
+ if not self.due:
67
+ self.due = time.monotonic() + splay(self.interval, self.share())
68
+ # nothing here may wait on the carrier: a thread importing what
69
+ # a post needs blocks while its starter is mid-import
70
+ self.thread = threading.Thread(target=self._serving, name=NAME, daemon=True)
71
+ self.thread.start()
72
+ self.woken.set()
73
+
74
+ def _serving(self) -> None:
75
+ while not self.leaving:
76
+ # a thread that dies takes delivery with it and tells nobody
77
+ with contextlib.suppress(Exception):
78
+ self.woken.wait(self._waiting())
79
+ self.woken.clear()
80
+ if time.monotonic() >= self.due:
81
+ self.offering()
82
+
83
+ def parting(self) -> None:
84
+ """Give whatever is held one bounded chance to travel, then give up."""
85
+ self.leaving = True
86
+ self.woken.set()
87
+ limit = time.monotonic() + PARTING
88
+ # a carrier mid-pass is doing this work, so the wait is that budget
89
+ if not self.passing.acquire(timeout=PARTING):
90
+ return
91
+ try:
92
+ with contextlib.suppress(Exception):
93
+ # bounded by what is left, or the bound is only the moment a
94
+ # last attempt may begin at
95
+ while (left := limit - time.monotonic()) > 0 and self._offered(left):
96
+ pass
97
+ finally:
98
+ self.passing.release()
99
+
100
+ def forked(self) -> None:
101
+ """Leave a child holding none of its parent's, and owing itself a carrier."""
102
+ # a lock or event held at the moment of the fork is held in the child
103
+ # forever, so each is made again rather than reset
104
+ self.guard = threading.Lock()
105
+ self.passing = threading.Lock()
106
+ self.woken = threading.Event()
107
+ self.thread = None
108
+ self.holding = None
109
+ # every worker of a pre-forking server was born in the same second
110
+ self.due = 0.0
111
+ self.tries = 0
112
+ # a child is not on its way out merely because its parent was
113
+ self.leaving = False
114
+ self.batcher.forked()
115
+ self.outbox.forked()
116
+
117
+ def registered(self) -> None:
118
+ """Ask the interpreter to say when this process has become two."""
119
+ # cannot be taken back once given, so exactly one per process
120
+ if hasattr(os, "register_at_fork"):
121
+ os.register_at_fork(after_in_child=self.forked)
122
+
123
+ def offering(self) -> None:
124
+ """Offer what is waiting, one pass at a time, stopping at a refusal."""
125
+ if not self.passing.acquire(blocking=False):
126
+ return
127
+ try:
128
+ for _ in range(self.outbox.limit):
129
+ if not self._offered(TIMEOUT):
130
+ return
131
+ finally:
132
+ self.passing.release()
133
+
134
+ def _offered(self, bound: float) -> bool:
135
+ """Offer one batch, and say whether offering another is worth trying."""
136
+ batch = self.holding or self.outbox.take()
137
+ if batch is None:
138
+ return False
139
+ # held before the attempt, so a cut-off leaves it findable
140
+ self.holding = batch
141
+ if (verdict := self.carry(self.told, batch, bound)) is not Verdict.DELIVERED:
142
+ self._stood_off(verdict)
143
+ return False
144
+ self.holding = None
145
+ self.due = 0.0
146
+ self.tries = 0
147
+ self.delivered += 1
148
+ return True
149
+
150
+ def _stood_off(self, verdict: Verdict) -> None:
151
+ """Wait longer for each refusal, and longest for one about nothing of ours."""
152
+ self.tries = TOPMOST if verdict is Verdict.STOP else self.tries + 1
153
+ self.due = time.monotonic() + standoff(self.tries, self.share())
154
+
155
+ def _waiting(self) -> float:
156
+ """Wait out a standoff if there is one, and otherwise a pass."""
157
+ left = self.due - time.monotonic()
158
+ return left if left > 0 else self.interval
@@ -0,0 +1,80 @@
1
+ """Map an imported module to the distribution that installed it."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from fluidattacks_agent.report import Confidence, Name, readable
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Dist:
10
+ """The distribution behind a module, and how well its version is known."""
11
+
12
+ name: Name
13
+ version: str | None
14
+ confidence: Confidence
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Declared:
19
+ """The versions read off installed metadata, and the ones that would not."""
20
+
21
+ versions: dict[Name, str]
22
+ unreadable: int
23
+
24
+
25
+ def declared() -> Declared:
26
+ """Read the version every installed distribution declares for itself."""
27
+ # imported here and not above: reading metadata costs twenty milliseconds of
28
+ # interpreter start, and it is a flush that needs it, not an import
29
+ from importlib.metadata import distributions # noqa: PLC0415
30
+
31
+ found: dict[Name, str] = {}
32
+ unreadable = 0
33
+ for dist in distributions():
34
+ # metadata is a file the workload owns: one unreadable distribution
35
+ # must not cost the map, since without it the probe reports nothing
36
+ try:
37
+ metadata = dist.metadata
38
+ name, version = metadata["Name"], metadata["Version"]
39
+ except Exception: # noqa: BLE001
40
+ unreadable += 1
41
+ continue
42
+ # the type keys it the way it will be looked up, which also folds two
43
+ # spellings of one project together, since PEP 503 says they are one
44
+ if name is not None and version is not None:
45
+ found.setdefault(Name(name), version)
46
+ return Declared(versions=found, unreadable=unreadable)
47
+
48
+
49
+ def resolve(names: list[str], versions: dict[Name, str]) -> Dist:
50
+ """Choose the distribution behind a module, and say how sure that is."""
51
+ name = Name(names[0])
52
+ version = versions.get(name)
53
+ # a version the reader would refuse is one we could not read, and dropping
54
+ # the record over it would lose the package instead of the version
55
+ if version is None or not readable(version):
56
+ return Dist(name=name, version=None, confidence=Confidence.UNKNOWN)
57
+ known = Confidence.EXACT if len(names) == 1 else Confidence.INFERRED
58
+ return Dist(name=name, version=version, confidence=known)
59
+
60
+
61
+ def distribution_map() -> dict[str, Dist]:
62
+ """
63
+ Read which distribution installed each top-level module, and its version.
64
+
65
+ This walks every installed distribution's metadata, so it is taken once per
66
+ report rather than once per module. The probe spends the workload's own cpu.
67
+ """
68
+ from importlib.metadata import packages_distributions # noqa: PLC0415
69
+
70
+ versions = declared().versions
71
+ return {
72
+ module: resolve(names, versions)
73
+ for module, names in packages_distributions().items()
74
+ if names
75
+ }
76
+
77
+
78
+ def distribution_of(module: str, installed: dict[str, Dist]) -> Dist | None:
79
+ """Name the distribution a module came from, submodules included."""
80
+ return installed.get(module.split(".", maxsplit=1)[0])
@@ -0,0 +1,122 @@
1
+ """Watch code inside a package start running, where the interpreter allows it."""
2
+
3
+ import sys
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass, field
6
+ from pathlib import PurePath
7
+ from typing import Final
8
+
9
+ from fluidattacks_agent.distributions import Dist, distribution_of
10
+ from fluidattacks_agent.report import Ecosystem, Evidence, Granularity, Record, keyed
11
+
12
+ # 3.15 makes the import below lazy from this; every version we support
13
+ # ignores it, so nothing changes until the floor moves
14
+ __lazy_modules__ = ["pathlib"]
15
+
16
+ MAX_FUNCTIONS: Final = 4096
17
+
18
+ OURS: Final = "fluidattacks_agent"
19
+
20
+ SOURCE: Final = ".py"
21
+
22
+ INITIALIZER: Final = "__init__"
23
+ PARENT: Final = ".."
24
+
25
+ TOOL: Final = 3
26
+ TOOL_NAME: Final = "fluidattacks-agent"
27
+
28
+
29
+ TOOLS: Final = 6
30
+
31
+
32
+ def available() -> bool:
33
+ """Say whether this interpreter can report code starting at all."""
34
+ return hasattr(sys, "monitoring")
35
+
36
+
37
+ def alone(tool: int = TOOL) -> bool:
38
+ """Say whether this is the only tool watching: restart_events takes none."""
39
+ held = (sys.monitoring.get_tool(other) for other in range(TOOLS) if other != tool)
40
+ return not any(name is not None for name in held)
41
+
42
+
43
+ def module_of(filename: str, sites: tuple[str, ...]) -> str | None:
44
+ """Name the module whose source a running code object came from."""
45
+ for site in sites:
46
+ prefix = site + "/"
47
+ if not filename.startswith(prefix) or not filename.endswith(SOURCE):
48
+ continue
49
+ held = PurePath(filename[len(prefix) :])
50
+ parts = list(held.parts)
51
+ if not parts or held.is_absolute() or PARENT in parts:
52
+ return None
53
+ stem = parts.pop()[: -len(SOURCE)]
54
+ if not stem:
55
+ return None
56
+ if stem != INITIALIZER:
57
+ parts.append(stem)
58
+ return ".".join(parts) or None
59
+ return None
60
+
61
+
62
+ @dataclass
63
+ class RunObserver:
64
+ """Functions that started, bounded the way the reader's own map is."""
65
+
66
+ sites: tuple[str, ...] = ()
67
+ seen: dict[str, int] = field(default_factory=dict)
68
+ moved: set[str] = field(default_factory=set)
69
+ suppressed: int = 0
70
+
71
+ def note(self, filename: str, qualname: str) -> str | None:
72
+ """Take note of code starting, naming the function whenever there is room."""
73
+ module = module_of(filename, self.sites)
74
+ if module is None:
75
+ return None
76
+ if module.split(".", maxsplit=1)[0] == OURS:
77
+ return None
78
+ qualified = f"{module}.{qualname}"
79
+ if qualified in self.seen:
80
+ self.seen[qualified] += 1
81
+ self.moved.add(qualified)
82
+ return qualified
83
+ if len(self.seen) >= MAX_FUNCTIONS:
84
+ self.suppressed += 1
85
+ return None
86
+ self.seen[qualified] = 1
87
+ self.moved.add(qualified)
88
+ return qualified
89
+
90
+ def records(
91
+ self,
92
+ installed: dict[str, Dist],
93
+ carried: Mapping[tuple[str, str], int],
94
+ ) -> list[Record]:
95
+ """Report every function whose package a distribution installed."""
96
+ found: list[Record] = []
97
+ scanned = bool(installed)
98
+ for qualified in sorted(self.moved):
99
+ times = self.seen[qualified]
100
+ # what a report carried comes back only if note sees it again,
101
+ # and no distribution will own what a settled scan does not
102
+ if times <= carried.get(keyed(Evidence.EXECUTED, qualified), 0):
103
+ self.moved.discard(qualified)
104
+ continue
105
+ dist = distribution_of(qualified, installed)
106
+ if dist is None:
107
+ if scanned:
108
+ self.moved.discard(qualified)
109
+ continue
110
+ found.append(
111
+ Record(
112
+ ecosystem=Ecosystem.PYPI,
113
+ name=dist.name,
114
+ version=dist.version,
115
+ confidence=dist.confidence,
116
+ symbol=qualified,
117
+ granularity=Granularity.FUNCTION,
118
+ evidence=Evidence.EXECUTED,
119
+ frequency=times,
120
+ ),
121
+ )
122
+ return found
@@ -0,0 +1,10 @@
1
+ """Start the probe on being imported, and only if a workload wants it."""
2
+
3
+ from fluidattacks_agent.switch import switched_on
4
+
5
+ # importing this runs it, because a site directory is what imports it. A
6
+ # workload that said no imports nothing above the switch: 26 ms of a start
7
+ if switched_on():
8
+ from fluidattacks_agent.startup import start
9
+
10
+ start()
@@ -0,0 +1,109 @@
1
+ """Account for package code the interpreter read without importing it."""
2
+
3
+ import os
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass, field
6
+ from types import ModuleType
7
+
8
+ from fluidattacks_agent.distributions import Dist, distribution_of
9
+ from fluidattacks_agent.report import Ecosystem, Evidence, Granularity, Record, keyed
10
+
11
+ MAX_PACKAGES = 4096
12
+
13
+ OURS = "fluidattacks_agent"
14
+
15
+ METADATA = (".dist-info", ".egg-info")
16
+
17
+ CACHE = "__pycache__"
18
+
19
+ INITIALIZER = "__init__"
20
+ PARENT = ".."
21
+
22
+ CODE = (".py", ".pyc", ".so", ".pyd")
23
+
24
+
25
+ def package_of(path: str, sites: tuple[str, ...]) -> str | None:
26
+ """Name the top level package a read file belongs to, when it is code."""
27
+ if not path.endswith(CODE):
28
+ return None
29
+ for site in sites:
30
+ prefix = site + os.sep
31
+ if not path.startswith(prefix):
32
+ continue
33
+ held = path[len(prefix) :]
34
+ # a step back out names a file the package it sits under does not own
35
+ if f"{os.sep}{PARENT}{os.sep}" in held:
36
+ return None
37
+ head, _, tail = held.partition(os.sep)
38
+ if head == CACHE:
39
+ head = tail.split(os.sep, maxsplit=1)[0]
40
+ if head.endswith(METADATA):
41
+ return None
42
+ named = head.split(".", maxsplit=1)[0]
43
+ return None if named == INITIALIZER else named or None
44
+ return None
45
+
46
+
47
+ @dataclass
48
+ class LoadObserver:
49
+ """Packages whose code was read, bounded the way the reader's map is."""
50
+
51
+ sites: tuple[str, ...] = ()
52
+ # how many files of each package were read, not merely whether any was
53
+ seen: dict[str, int] = field(default_factory=dict)
54
+ moved: set[str] = field(default_factory=set)
55
+ suppressed: int = 0
56
+
57
+ def note(self, path: str) -> str | None:
58
+ """Take note of a file opened, naming the package whenever there is room."""
59
+ package = package_of(path, self.sites)
60
+ if package is None or package == OURS:
61
+ return None
62
+ if package in self.seen:
63
+ self.seen[package] += 1
64
+ self.moved.add(package)
65
+ return package
66
+ if len(self.seen) >= MAX_PACKAGES:
67
+ self.suppressed += 1
68
+ return None
69
+ self.seen[package] = 1
70
+ self.moved.add(package)
71
+ return package
72
+
73
+ def records(
74
+ self,
75
+ modules: dict[str, ModuleType],
76
+ installed: dict[str, Dist],
77
+ carried: Mapping[tuple[str, str], int],
78
+ ) -> list[Record]:
79
+ """Report the packages that were read and never became modules."""
80
+ found: list[Record] = []
81
+ scanned = bool(installed)
82
+ for package in sorted(self.moved):
83
+ times = self.seen[package]
84
+ if times <= carried.get(keyed(Evidence.READ, package), 0):
85
+ self.moved.discard(package)
86
+ continue
87
+ if package in modules:
88
+ # kept, because a name can leave sys.modules and be read again
89
+ continue
90
+ dist = distribution_of(package, installed)
91
+ if dist is None:
92
+ if scanned:
93
+ self.moved.discard(package)
94
+ continue
95
+ found.append(
96
+ Record(
97
+ ecosystem=Ecosystem.PYPI,
98
+ name=dist.name,
99
+ version=dist.version,
100
+ confidence=dist.confidence,
101
+ symbol=package,
102
+ # a read file names the package it sits in, and naming the
103
+ # module it is would claim the interpreter reached it
104
+ granularity=Granularity.PACKAGE,
105
+ evidence=Evidence.READ,
106
+ frequency=times,
107
+ ),
108
+ )
109
+ return found