Coverage for pepdbagent/modules/namespace.py: 91%
101 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-01 22:07 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-01 22:07 +0000
1import logging
2from collections import Counter
3from datetime import datetime, timedelta
5from sqlalchemy import delete, distinct, func, or_, select
6from sqlalchemy.orm import Session
7from sqlalchemy.sql.selectable import Select
9from pepdbagent.const import DEFAULT_LIMIT, DEFAULT_LIMIT_INFO, DEFAULT_OFFSET, PKG_NAME
10from pepdbagent.db_utils import BaseEngine, Projects, TarNamespace, User
11from pepdbagent.exceptions import NamespaceNotFoundError
12from pepdbagent.models import (
13 ListOfNamespaceInfo,
14 Namespace,
15 NamespaceInfo,
16 NamespaceList,
17 NamespaceStats,
18 PaginationResult,
19 TarNamespaceModel,
20 TarNamespaceModelReturn,
21)
22from pepdbagent.utils import tuple_converter
24_LOGGER = logging.getLogger(PKG_NAME)
27class PEPDatabaseNamespace:
28 """
29 Class that represents project Namespaces in Database.
31 While using this class, user can retrieve all necessary metadata about PEPs
32 """
34 def __init__(self, pep_db_engine: BaseEngine):
35 """
36 Args:
37 pep_db_engine: PEPDatabaseAgent engine object.
38 """
39 self._sa_engine = pep_db_engine.engine
40 self._pep_db_engine = pep_db_engine
42 def get(
43 self,
44 query: str = "",
45 admin: list[str] | str | None = None,
46 limit: int = DEFAULT_LIMIT,
47 offset: int = DEFAULT_OFFSET,
48 ) -> NamespaceList:
49 """Search available namespaces in the database.
51 Args:
52 query: Search string.
53 admin: Namespaces where the user has admin rights.
54 limit: Maximum number of results to return.
55 offset: Number of results to skip.
57 Returns:
58 NamespaceList with count, limit, offset, and results.
59 """
60 _LOGGER.info(
61 f"Getting namespaces annotation with provided info: (query: {query})"
62 )
63 admin_tuple = tuple_converter(admin)
64 return NamespaceList(
65 count=self._count_namespace(search_str=query, admin_nsp=admin_tuple),
66 limit=limit,
67 offset=offset,
68 results=self._get_namespace(
69 search_str=query,
70 admin_nsp=admin_tuple,
71 limit=limit,
72 offset=offset,
73 ),
74 )
76 def _get_namespace(
77 self,
78 search_str: str,
79 admin_nsp: tuple = None,
80 limit: int = DEFAULT_LIMIT,
81 offset: int = DEFAULT_OFFSET,
82 ) -> list[Namespace]:
83 """Search for namespaces matching a search string.
85 Args:
86 search_str: Keywords to search in namespace names.
87 admin_nsp: Namespaces accessible even when private.
88 limit: Maximum number of results.
89 offset: Number of results to skip.
91 Returns:
92 List of Namespace objects.
93 """
94 statement = (
95 select(
96 Projects.namespace,
97 func.count(Projects.name).label("number_of_projects"),
98 func.sum(Projects.number_of_samples).label("number_of_samples"),
99 )
100 .group_by(Projects.namespace)
101 .select_from(Projects)
102 )
104 statement = self._add_condition(
105 statement=statement,
106 search_str=search_str,
107 admin_list=admin_nsp,
108 )
110 statement = statement.limit(limit).offset(offset)
112 with Session(self._sa_engine) as session:
113 query_results = session.execute(statement).all()
115 results_list = []
116 for res in query_results:
117 results_list.append(
118 Namespace(
119 namespace=res.namespace,
120 number_of_projects=res.number_of_projects,
121 number_of_samples=res.number_of_samples,
122 )
123 )
124 return results_list
126 def _count_namespace(
127 self, search_str: str = None, admin_nsp: tuple = tuple()
128 ) -> int:
129 """Count namespaces matching a search string.
131 Args:
132 search_str: Keywords to search in namespace names.
133 admin_nsp: Namespaces accessible even when private.
135 Returns:
136 Number of matching namespaces.
137 """
138 statement = select(
139 func.count(distinct(Projects.namespace)).label("number_of_namespaces")
140 ).select_from(Projects)
141 statement = self._add_condition(
142 statement=statement,
143 search_str=search_str,
144 admin_list=admin_nsp,
145 )
146 with Session(self._sa_engine) as session:
147 query_results = session.execute(statement).one()
149 return query_results.number_of_namespaces
151 @staticmethod
152 def _add_condition(
153 statement: Select,
154 search_str: str = None,
155 admin_list: tuple[str, ...] | list[str] | str | None = None,
156 ) -> Select:
157 """Add a WHERE clause to a namespace search statement.
159 Args:
160 statement: SQLAlchemy SELECT statement to augment.
161 search_str: String to search in namespace names.
162 admin_list: Namespaces with admin/private access.
164 Returns:
165 Statement with WHERE clause applied.
166 """
167 if search_str:
168 sql_search_str = f"%{search_str}%"
169 statement = statement.where(
170 or_(
171 Projects.namespace.ilike(sql_search_str),
172 )
173 )
174 statement = statement.where(
175 or_(Projects.private.is_(False), Projects.namespace.in_(admin_list))
176 )
177 return statement
179 def info(
180 self,
181 page: int = 0,
182 page_size: int = DEFAULT_LIMIT_INFO,
183 order_by: str = "number_of_projects",
184 ) -> ListOfNamespaceInfo:
185 """Get a paginated list of top namespaces.
187 Warning: counts all projects including private ones (by design, for efficiency).
189 Args:
190 page: Page number (zero-based).
191 page_size: Number of namespaces per page.
192 order_by: Sort field — "number_of_projects" or "number_of_schemas".
194 Returns:
195 ListOfNamespaceInfo with pagination metadata and results.
196 """
198 statement = select(User)
200 if order_by == "number_of_projects":
201 statement = statement.order_by(User.number_of_projects.desc())
202 elif order_by == "number_of_schemas":
203 statement = statement.order_by(User.number_of_schemas.desc())
205 with Session(self._sa_engine) as session:
206 results = session.scalars(
207 statement.limit(page_size).offset(page_size * page)
208 )
209 total_number_of_namespaces = session.execute(
210 select(func.count(User.id))
211 ).one()[0]
213 list_of_results = []
214 for result in results:
215 list_of_results.append(
216 NamespaceInfo(
217 namespace_name=result.namespace,
218 contact_url=f"https://github.com/{result.namespace}",
219 number_of_projects=result.number_of_projects,
220 number_of_schemas=result.number_of_schemas,
221 )
222 )
223 return ListOfNamespaceInfo(
224 pagination=PaginationResult(
225 page=page,
226 page_size=page_size,
227 total=total_number_of_namespaces,
228 ),
229 results=list_of_results,
230 )
232 def stats(self, namespace: str = None, monthly: bool = False) -> NamespaceStats:
233 """Get submission/update statistics for a namespace or the whole database.
235 Args:
236 namespace: Namespace to filter by (default: all namespaces).
237 monthly: Return monthly stats for 3 years if True, daily stats for 3 months if False.
239 Returns:
240 NamespaceStats with projects_updated and projects_created histograms.
241 """
242 if monthly:
243 number_of_month = 12 * 3
244 else:
245 number_of_month = 3
246 today_date = datetime.today().date() + timedelta(days=1)
247 three_month_ago = today_date - timedelta(days=number_of_month * 30 + 1)
248 statement_last_update = select(Projects.last_update_date).filter(
249 Projects.last_update_date.between(three_month_ago, today_date)
250 )
251 statement_create_date = select(Projects.submission_date).filter(
252 Projects.submission_date.between(three_month_ago, today_date)
253 )
254 if namespace:
255 statement_last_update = statement_last_update.where(
256 Projects.namespace == namespace
257 )
258 statement_create_date = statement_create_date.where(
259 Projects.namespace == namespace
260 )
262 with Session(self._sa_engine) as session:
263 update_results = session.execute(statement_last_update).all()
264 create_results = session.execute(statement_create_date).all()
266 if not update_results:
267 raise NamespaceNotFoundError(
268 f"Namespace {namespace} not found in the database"
269 )
271 if monthly:
272 year_month_str_submission = [
273 dt.submission_date.strftime("%Y-%m") for dt in create_results
274 ]
275 year_month_str_last_update = [
276 dt.last_update_date.strftime("%Y-%m") for dt in update_results
277 ]
278 else:
279 year_month_str_submission = [
280 dt.submission_date.strftime("%Y-%m-%d") for dt in create_results
281 ]
282 year_month_str_last_update = [
283 dt.last_update_date.strftime("%Y-%m-%d") for dt in update_results
284 ]
286 counts_submission = dict(Counter(year_month_str_submission))
287 counts_last_update = dict(Counter(year_month_str_last_update))
289 return NamespaceStats(
290 namespace=namespace,
291 projects_updated=counts_last_update,
292 projects_created=counts_submission,
293 )
295 def upload_tar_info(self, tar_info: TarNamespaceModel) -> None:
296 """Upload metadata for a namespace tar archive.
298 Args:
299 tar_info: Tar archive metadata.
300 """
302 with Session(self._sa_engine) as session:
303 new_tar = TarNamespace(
304 file_path=tar_info.file_path,
305 namespace=tar_info.namespace,
306 creation_date=tar_info.creation_date,
307 number_of_projects=tar_info.number_of_projects,
308 file_size=tar_info.file_size,
309 )
310 session.add(new_tar)
311 session.commit()
313 _LOGGER.info("Geo tar info was uploaded successfully!")
315 def get_tar_info(self, namespace: str) -> TarNamespaceModelReturn:
316 """Get metadata for namespace tar archives.
318 Args:
319 namespace: Namespace of the tar files.
321 Returns:
322 TarNamespaceModelReturn with count and list of archive metadata.
323 """
325 with Session(self._sa_engine) as session:
326 tar_info = session.scalars(
327 select(TarNamespace)
328 .where(TarNamespace.namespace == namespace)
329 .order_by(TarNamespace.creation_date.desc())
330 )
332 results = []
333 for result in tar_info:
334 results.append(
335 TarNamespaceModel(
336 identifier=result.id,
337 namespace=result.namespace,
338 file_path=result.file_path,
339 creation_date=result.creation_date,
340 number_of_projects=result.number_of_projects,
341 file_size=result.file_size,
342 )
343 )
345 return TarNamespaceModelReturn(count=len(results), results=results)
347 def delete_tar_info(self, namespace: str = None) -> None:
348 """Delete tar archive metadata for a namespace.
350 Args:
351 namespace: Namespace to delete archives for (default: all namespaces).
352 """
354 with Session(self._sa_engine) as session:
355 delete_statement = delete(TarNamespace)
356 if namespace:
357 delete_statement = delete_statement.where(
358 TarNamespace.namespace == namespace
359 )
360 session.execute(delete_statement)
361 session.commit()
362 _LOGGER.info("Geo tar info was deleted successfully!")